From 91dd6dbef421171517bdabd8a9480e71741caff7 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 3 Jul 2025 11:27:08 +0000
Subject: [PATCH] Refactor: Replace AppIntro library with Compose
implementation (#2332)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/build.gradle.kts | 1 -
app/src/main/AndroidManifest.xml | 3 -
.../com/geeksville/mesh/AppIntroduction.kt | 78 -------
.../java/com/geeksville/mesh/MainActivity.kt | 52 ++---
.../mesh/ui/intro/AppIntroComponents.kt | 214 ++++++++++++++++++
app/src/main/res/values/strings.xml | 4 +
gradle/libs.versions.toml | 2 -
7 files changed, 244 insertions(+), 110 deletions(-)
delete mode 100644 app/src/main/java/com/geeksville/mesh/AppIntroduction.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f6e18b516..400e2a425 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 01dc21d8c..0348f541b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -225,9 +225,6 @@
-
-
.
- */
-
-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()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 5e3e22363..1a3e4fefc 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -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)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt b/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt
new file mode 100644
index 000000000..3bc5e9335
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/intro/AppIntroComponents.kt
@@ -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 .
+ */
+
+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 {
+ 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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a1740630f..1a2931e37 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -707,4 +707,8 @@
Scroll to bottom
Meshtastic
Scanning
+ Back
+ Next
+ Done
+ Skip
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f44872e70..b8e7b0c2b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }