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" }