mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-29 03:03:05 -04:00
Refactor: Replace AppIntro library with Compose implementation (#2332)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user