Refactor: Replace AppIntro library with Compose implementation (#2332)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-07-03 11:27:08 +00:00
committed by GitHub
parent cca51e765a
commit 91dd6dbef4
7 changed files with 244 additions and 110 deletions

View File

@@ -199,7 +199,6 @@ dependencies {
implementation(libs.zxing.core)
// Individual dependencies
implementation(libs.appintro)
"googleImplementation"(libs.awesome.app.rating)
implementation(libs.core.splashscreen)
implementation(libs.emoji2.emojipicker)

View File

@@ -225,9 +225,6 @@
</receiver>
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
<activity
android:name=".AppIntroduction"/>
<!-- allow for plugin discovery -->
<activity
android:name="com.atakmap.app.component"

View File

@@ -1,78 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import android.os.Bundle
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import com.geeksville.mesh.model.UIViewModel
import com.github.appintro.AppIntro
import com.github.appintro.AppIntroFragment
class AppIntroduction : AppIntro() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Make sure you don't call setContentView!
// Call addSlide passing your Fragments.
// You can use AppIntroFragment to use a pre-built fragment
addSlide(
AppIntroFragment.createInstance(
title = resources.getString(R.string.intro_welcome),
description = resources.getString(R.string.intro_welcome_text),
imageDrawable = R.mipmap.ic_launcher2_round,
backgroundColorRes = R.color.colourGrey,
descriptionColorRes = R.color.colorOnPrimary
))
addSlide(AppIntroFragment.createInstance(
title = resources.getString(R.string.intro_started),
description = resources.getString(R.string.intro_started_text),
imageDrawable = R.drawable.icon_meanings,
backgroundColorRes = R.color.colourGrey,
descriptionColorRes = R.color.colorOnPrimary
))
addSlide(AppIntroFragment.createInstance(
title = resources.getString(R.string.intro_encryption),
description = resources.getString(R.string.intro_encryption_text),
imageDrawable = R.drawable.channel_name_image,
backgroundColorRes = R.color.colourGrey,
descriptionColorRes = R.color.colorOnPrimary
))
//addSlide(SlideTwoFragment())
}
private fun done() {
val prefs = UIViewModel.getPreferences(this)
prefs.edit { putBoolean("app_intro_completed", true) }
finish()
}
override fun onSkipPressed(currentFragment: Fragment?) {
super.onSkipPressed(currentFragment)
// Decide what to do when the user clicks on "Skip"
done()
}
override fun onDonePressed(currentFragment: Fragment?) {
super.onDonePressed(currentFragment)
// Decide what to do when the user clicks on "Done"
done()
}
}

View File

@@ -40,6 +40,8 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalView
import androidx.core.content.edit
import androidx.core.net.toUri
@@ -67,6 +69,7 @@ import com.geeksville.mesh.ui.MainMenuAction
import com.geeksville.mesh.ui.MainScreen
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC
import com.geeksville.mesh.ui.intro.AppIntroductionScreen
import com.geeksville.mesh.util.Exceptions
import com.geeksville.mesh.util.LanguageUtils
import com.geeksville.mesh.util.getPackageInfoCompat
@@ -82,6 +85,8 @@ class MainActivity : AppCompatActivity(), Logging {
@Inject
internal lateinit var serviceRepository: ServiceRepository
private var showAppIntro by mutableStateOf(false)
private val bluetoothPermissionsLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result.entries.all { it.value }) {
@@ -110,18 +115,17 @@ class MainActivity : AppCompatActivity(), Logging {
installSplashScreen()
super.onCreate(savedInstanceState)
val prefs = UIViewModel.getPreferences(this)
if (savedInstanceState == null) {
val prefs = UIViewModel.getPreferences(this)
// First run: migrate in-app language prefs to appcompat
val lang = prefs.getString("lang", LanguageUtils.SYSTEM_DEFAULT)
if (lang != LanguageUtils.SYSTEM_MANAGED) LanguageUtils.migrateLanguagePrefs(prefs)
info("in-app language is ${LanguageUtils.getLocale()}")
// First run: show AppIntroduction
if (!prefs.getBoolean("app_intro_completed", false)) {
startActivity(Intent(this, AppIntroduction::class.java))
showAppIntro = true
} else {
(application as GeeksvilleApplication).askToRate(this)
}
// Ask user to rate in play store
(application as GeeksvilleApplication).askToRate(this)
}
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -145,14 +149,22 @@ class MainActivity : AppCompatActivity(), Logging {
AppCompatDelegate.setDefaultNightMode(theme)
}
}
MainScreen(
uIViewModel = model,
bluetoothViewModel = bluetoothViewModel,
onAction = ::onMainMenuAction,
)
if (showAppIntro) {
AppIntroductionScreen(onDone = {
prefs.edit { putBoolean("app_intro_completed", true) }
showAppIntro = false
(application as GeeksvilleApplication).askToRate(this@MainActivity)
})
} else {
MainScreen(
uIViewModel = model,
bluetoothViewModel = bluetoothViewModel,
onAction = ::onMainMenuAction,
)
}
}
}
// Handle any intent
handleIntent(intent)
}
@@ -161,7 +173,6 @@ class MainActivity : AppCompatActivity(), Logging {
handleIntent(intent)
}
// Handle any intents that were passed into us
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action
val appLinkData: Uri? = intent.data
@@ -249,7 +260,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
// Called when we gain/lose a connection to our mesh radio
private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) {
if (newConnection == MeshService.ConnectionState.CONNECTED) {
checkNotificationPermissions()
@@ -322,7 +332,6 @@ class MainActivity : AppCompatActivity(), Logging {
val connectionState =
MeshService.ConnectionState.valueOf(service.connectionState())
// We won't receive a notify for the initial state of connection, so we force an update here
onMeshConnectionChanged(connectionState)
} catch (ex: RemoteException) {
errormsg("Device error during init ${ex.message}")
@@ -341,13 +350,11 @@ class MainActivity : AppCompatActivity(), Logging {
private fun bindMeshService() {
debug("Binding to mesh service!")
try {
MeshService.startService(this) // Start the service so it stays running even after we unbind
MeshService.startService(this)
} catch (ex: Exception) {
// Old samsung phones have a race condition andthis might rarely fail. Which is probably find because the bind will be sufficient most of the time
errormsg("Failed to start service from activity - but ignoring because bind will work ${ex.message}")
}
// ALSO bind so we can use the api
mesh.connect(
this,
MeshService.createIntent(),
@@ -381,7 +388,6 @@ class MainActivity : AppCompatActivity(), Logging {
try {
bindMeshService()
} catch (ex: BindFailedException) {
// App is probably shutting down, ignore
errormsg("Bind of MeshService failed${ex.message}")
}
}
@@ -414,7 +420,7 @@ class MainActivity : AppCompatActivity(), Logging {
}
MainMenuAction.SHOW_INTRO -> {
startActivity(Intent(this, AppIntroduction::class.java))
showAppIntro = true // Show intro again if selected from menu
}
else -> {}
@@ -431,8 +437,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
// Theme functions
private fun chooseThemeDialog() {
val styles = mapOf(
getString(R.string.dynamic) to MODE_DYNAMIC,
@@ -441,11 +445,9 @@ class MainActivity : AppCompatActivity(), Logging {
getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
)
// Load preferences and its value
val prefs = UIViewModel.getPreferences(this)
val theme = prefs.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
debug("Theme from prefs: $theme")
// map theme keys to function to set theme
model.showAlert(
title = getString(R.string.choose_theme),
message = "",
@@ -459,10 +461,8 @@ class MainActivity : AppCompatActivity(), Logging {
private fun chooseLangDialog() {
val languageTags = LanguageUtils.getLanguageTags(this)
// Load preferences and its value
val lang = LanguageUtils.getLocale()
debug("Lang from prefs: $lang")
// map lang keys to function to set locale
val langMap = languageTags.mapValues { (_, value) ->
{
LanguageUtils.setLocale(value)

View File

@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.intro
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.components.AutoLinkText
import kotlinx.coroutines.launch
// Data class for a slide
private data class IntroSlide(
val title: String,
val description: String,
@DrawableRes val imageRes: Int,
)
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit) {
val slides = slides()
val pagerState = rememberPagerState(pageCount = { slides.size })
val scope = rememberCoroutineScope()
Scaffold(
bottomBar = {
Surface(shadowElevation = 8.dp) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (pagerState.currentPage < slides.size - 1) {
TextButton(onClick = onDone) {
Text(stringResource(id = R.string.app_intro_skip_button))
}
} else {
TextButton(onClick = {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}) {
Text(stringResource(id = R.string.app_intro_back_button))
}
}
PagerIndicator(
slideCount = slides.size,
currentPage = pagerState.currentPage,
modifier = Modifier.weight(1f)
)
if (pagerState.currentPage < slides.size - 1) {
TextButton(
onClick = {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
) {
Text(stringResource(id = R.string.app_intro_next_button))
}
} else {
Button(onClick = onDone) {
Text(stringResource(id = R.string.app_intro_done_button))
}
}
}
}
}
) { innerPadding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) { page ->
IntroScreenContent(slides[page])
}
}
}
@Composable
private fun slides(): List<IntroSlide> {
val slides = listOf(
IntroSlide(
title = stringResource(R.string.intro_welcome),
description = stringResource(R.string.intro_welcome_text),
imageRes = R.drawable.app_icon,
),
IntroSlide(
title = stringResource(R.string.intro_started),
description = stringResource(R.string.intro_started_text),
imageRes = R.drawable.icon_meanings,
),
IntroSlide(
title = stringResource(R.string.intro_encryption),
description = stringResource(R.string.intro_encryption_text),
imageRes = R.drawable.channel_name_image,
)
)
return slides
}
@Composable
private fun IntroScreenContent(slide: IntroSlide) {
Surface(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(id = slide.imageRes),
contentDescription = slide.title,
modifier = Modifier
.size(200.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = slide.title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
AutoLinkText(
text = slide.description,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
@Composable
fun PagerIndicator(slideCount: Int, currentPage: Int, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
repeat(slideCount) { iteration ->
val color =
if (currentPage == iteration) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.2f
)
}
Box(
modifier = Modifier
.padding(4.dp)
.clip(CircleShape)
.background(color)
.size(12.dp)
)
}
}
}

View File

@@ -707,4 +707,8 @@
<string name="scroll_to_bottom">Scroll to bottom</string>
<string name="meshtastic">Meshtastic</string>
<string name="scanning">Scanning</string>
<string name="app_intro_back_button">Back</string>
<string name="app_intro_next_button">Next</string>
<string name="app_intro_done_button">Done</string>
<string name="app_intro_skip_button">Skip</string>
</resources>

View File

@@ -3,7 +3,6 @@ adaptive = "1.2.0-alpha08"
adaptive-navigation-suite = "1.3.2"
agp = "8.11.0"
appcompat = "1.7.1"
appintro = "6.3.1"
awesome-app-rating = "2.8.0"
coil = "3.2.0"
compose-bom = "2025.06.01"
@@ -60,7 +59,6 @@ adaptive-navigation-android = { group = "androidx.compose.material3.adaptive", n
adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "adaptive-navigation-suite" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
appcompat-resources = { group = "androidx.appcompat", name = "appcompat-resources", version.ref = "appcompat" }
appintro = { group = "com.github.AppIntro", name = "AppIntro", version.ref = "appintro" }
awesome-app-rating = { group = "com.suddenh4x.ratingdialog", name = "awesome-app-rating", version.ref = "awesome-app-rating" }
coil = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-core = { group = "io.coil-kt.coil3", name = "coil-network-core", version.ref = "coil" }