mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-11 16:15:24 -04:00
refactor(intro): migrate UI screens from androidMain to commonMain (OB-T101)
- Create IntroPermissions and IntroSettingsNavigator abstractions in commonMain - Move all screens, nav graph, and helpers from androidMain to commonMain - Add AndroidIntroPermissions/AndroidIntroSettingsNavigator adapters (Accompanist) - Add JVM stubs with always-granted permissions and no-op settings nav - AppIntroductionScreen remains in androidMain as thin CompositionLocal host - Add CMP @PreviewLightDark previews for all 5 intro screens - Update spec 010-onboarding tasks.md: OB-T101 complete (17/19) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
|
||||
# Format: ## YYYY-MM-DD — <summary>
|
||||
|
||||
## 2026-05-11 — Migrated feature/intro UI to commonMain
|
||||
- Moved intro onboarding UI composables and nav graph from `feature/intro/src/androidMain/` into `feature/intro/src/commonMain/`, adding shared `IntroPermissions` and `IntroSettingsNavigator` interfaces plus a common `introGraph` Navigation 3 extension.
|
||||
- Refactored `AppIntroductionScreen` into a thin Android host that provides Android permission/settings adapters via composition locals, and added `AndroidIntroPermissions`, `AndroidIntroSettingsNavigator`, and JVM desktop no-op stubs.
|
||||
- Verified with `./gradlew spotlessApply :feature:intro:compileKotlinJvm :feature:intro:compileAndroidMain`.
|
||||
|
||||
## 2026-05-11 — Added Esp32OtaUpdateHandler common tests
|
||||
- Created `feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt`.
|
||||
- Covered WiFi OTA success flow, download/upload progress reporting, connection-drop error handling, hash rejection, verification timeout, and cancellation propagation.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.intro
|
||||
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
internal class AndroidIntroPermissions(
|
||||
private val bluetoothState: MultiplePermissionsState,
|
||||
private val locationState: MultiplePermissionsState,
|
||||
private val notificationState: PermissionState?,
|
||||
) : IntroPermissions {
|
||||
override val bluetooth: IntroPermissionState =
|
||||
object : IntroPermissionState {
|
||||
override val isGranted: Boolean
|
||||
get() = bluetoothState.allPermissionsGranted
|
||||
|
||||
override fun launchRequest() = bluetoothState.launchMultiplePermissionRequest()
|
||||
}
|
||||
|
||||
override val location: IntroPermissionState =
|
||||
object : IntroPermissionState {
|
||||
override val isGranted: Boolean
|
||||
get() = locationState.allPermissionsGranted
|
||||
|
||||
override fun launchRequest() = locationState.launchMultiplePermissionRequest()
|
||||
}
|
||||
|
||||
override val notification: IntroPermissionState? =
|
||||
notificationState?.let { state ->
|
||||
object : IntroPermissionState {
|
||||
override val isGranted: Boolean
|
||||
get() = state.status.isGranted
|
||||
|
||||
override fun launchRequest() = state.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import org.meshtastic.core.service.NotificationChannels
|
||||
|
||||
internal class AndroidIntroSettingsNavigator(private val context: Context) : IntroSettingsNavigator {
|
||||
override fun openAppSettings() {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
override fun openCriticalAlertsSettings() {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.ALERTS)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ package org.meshtastic.feature.intro
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
@@ -35,6 +39,8 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val notificationPermissionState: PermissionState? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -50,23 +56,28 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
// On older versions, location permission is used for scanning.
|
||||
emptyList()
|
||||
}
|
||||
val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
|
||||
|
||||
val permissions =
|
||||
remember(notificationPermissionState, locationPermissionState, bluetoothPermissionState) {
|
||||
AndroidIntroPermissions(
|
||||
bluetoothState = bluetoothPermissionState,
|
||||
locationState = locationPermissionState,
|
||||
notificationState = notificationPermissionState,
|
||||
)
|
||||
}
|
||||
val settingsNavigator = remember(context) { AndroidIntroSettingsNavigator(context) }
|
||||
val backStack = rememberNavBackStack(Welcome)
|
||||
|
||||
MeshtasticNavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider =
|
||||
introNavGraph(
|
||||
CompositionLocalProvider(
|
||||
LocalIntroPermissions provides permissions,
|
||||
LocalIntroSettingsNavigator provides settingsNavigator,
|
||||
) {
|
||||
MeshtasticNavDisplay(
|
||||
backStack = backStack,
|
||||
viewModel = viewModel,
|
||||
notificationPermissionState = notificationPermissionState,
|
||||
bluetoothPermissionState = bluetoothPermissionState,
|
||||
locationPermissionState = locationPermissionState,
|
||||
onDone = onDone,
|
||||
),
|
||||
)
|
||||
entryProvider = entryProvider { introGraph(backStack = backStack, viewModel = viewModel, onDone = onDone) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.bluetooth_feature_config
|
||||
import org.meshtastic.core.resources.bluetooth_feature_config_description
|
||||
@@ -34,6 +32,7 @@ import org.meshtastic.core.resources.settings
|
||||
import org.meshtastic.core.ui.icon.Antenna
|
||||
import org.meshtastic.core.ui.icon.Bluetooth
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/**
|
||||
* Screen for configuring Bluetooth permissions during the app introduction. It explains why Bluetooth permissions are
|
||||
@@ -43,12 +42,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
* button.
|
||||
* @param onSkip Callback invoked if the user chooses to skip Bluetooth permission setup.
|
||||
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
|
||||
* @param onOpenSettings Callback invoked when the user taps the settings link.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
internal fun BluetoothScreen(
|
||||
showNextButton: Boolean,
|
||||
onSkip: () -> Unit,
|
||||
onConfigure: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
val annotatedString =
|
||||
context.createClickableAnnotatedString(
|
||||
createClickableAnnotatedString(
|
||||
fullTextRes = Res.string.permission_missing_31,
|
||||
linkTextRes = Res.string.settings,
|
||||
tag = SETTINGS_TAG,
|
||||
@@ -75,10 +79,12 @@ internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConf
|
||||
onSkip = onSkip,
|
||||
onConfigure = onConfigure,
|
||||
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_bluetooth_permissions,
|
||||
onAnnotationClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onAnnotationClick = { onOpenSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun BluetoothScreenPreview() {
|
||||
AppTheme { Surface { BluetoothScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
|
||||
}
|
||||
@@ -25,12 +25,14 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -38,6 +40,7 @@ import org.meshtastic.core.resources.configure_critical_alerts
|
||||
import org.meshtastic.core.resources.critical_alerts
|
||||
import org.meshtastic.core.resources.critical_alerts_dnd_request_text
|
||||
import org.meshtastic.core.resources.skip
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/**
|
||||
* Screen for explaining and guiding the user to configure critical alert settings. This screen is part of the app
|
||||
@@ -77,3 +80,9 @@ internal fun CriticalAlertsScreen(onSkip: () -> Unit, onConfigure: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun CriticalAlertsScreenPreview() {
|
||||
AppTheme { Surface { CriticalAlertsScreen(onSkip = {}, onConfigure = {}) } }
|
||||
}
|
||||
@@ -16,36 +16,17 @@
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import org.meshtastic.core.service.NotificationChannels
|
||||
|
||||
/**
|
||||
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
|
||||
* Core Connection -> Shared Location -> Notifications.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
/** Navigation graph for the application introduction / onboarding flow. */
|
||||
@Suppress("LongMethod")
|
||||
internal fun introNavGraph(
|
||||
internal fun EntryProviderScope<NavKey>.introGraph(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
viewModel: IntroViewModel,
|
||||
notificationPermissionState: PermissionState?,
|
||||
bluetoothPermissionState: MultiplePermissionsState,
|
||||
locationPermissionState: MultiplePermissionsState,
|
||||
onDone: () -> Unit,
|
||||
) = entryProvider {
|
||||
val context = LocalContext.current
|
||||
|
||||
) {
|
||||
fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
|
||||
val next = viewModel.getNextKey(current, permissionsGranted)
|
||||
if (next != null) {
|
||||
@@ -58,7 +39,9 @@ internal fun introNavGraph(
|
||||
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
|
||||
|
||||
entry<Bluetooth> {
|
||||
val isGranted = bluetoothPermissionState.allPermissionsGranted
|
||||
val permissions = LocalIntroPermissions.current
|
||||
val settingsNavigator = LocalIntroSettingsNavigator.current
|
||||
val isGranted = permissions.bluetooth.isGranted
|
||||
BluetoothScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = { navigateToNext(Bluetooth) },
|
||||
@@ -66,14 +49,17 @@ internal fun introNavGraph(
|
||||
if (isGranted) {
|
||||
navigateToNext(Bluetooth)
|
||||
} else {
|
||||
bluetoothPermissionState.launchMultiplePermissionRequest()
|
||||
permissions.bluetooth.launchRequest()
|
||||
}
|
||||
},
|
||||
onOpenSettings = { settingsNavigator.openAppSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<Location> {
|
||||
val isGranted = locationPermissionState.allPermissionsGranted
|
||||
val permissions = LocalIntroPermissions.current
|
||||
val settingsNavigator = LocalIntroSettingsNavigator.current
|
||||
val isGranted = permissions.location.isGranted
|
||||
LocationScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = { navigateToNext(Location) },
|
||||
@@ -81,37 +67,38 @@ internal fun introNavGraph(
|
||||
if (isGranted) {
|
||||
navigateToNext(Location)
|
||||
} else {
|
||||
locationPermissionState.launchMultiplePermissionRequest()
|
||||
permissions.location.launchRequest()
|
||||
}
|
||||
},
|
||||
onOpenSettings = { settingsNavigator.openAppSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<Notifications> {
|
||||
val isGranted = notificationPermissionState?.status?.isGranted ?: true
|
||||
val permissions = LocalIntroPermissions.current
|
||||
val settingsNavigator = LocalIntroSettingsNavigator.current
|
||||
val notificationPermission = permissions.notification
|
||||
val isGranted = notificationPermission?.isGranted ?: true
|
||||
NotificationsScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = onDone,
|
||||
onConfigure = {
|
||||
if (notificationPermissionState != null && !isGranted) {
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
if (notificationPermission != null && !isGranted) {
|
||||
notificationPermission.launchRequest()
|
||||
} else {
|
||||
navigateToNext(Notifications, permissionsGranted = isGranted)
|
||||
}
|
||||
},
|
||||
onOpenSettings = { settingsNavigator.openAppSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<CriticalAlerts> {
|
||||
val settingsNavigator = LocalIntroSettingsNavigator.current
|
||||
CriticalAlertsScreen(
|
||||
onSkip = onDone,
|
||||
onConfigure = {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.ALERTS)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
settingsNavigator.openCriticalAlertsSettings()
|
||||
onDone()
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.intro
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/** Platform-agnostic permission state for the intro flow. */
|
||||
interface IntroPermissionState {
|
||||
val isGranted: Boolean
|
||||
|
||||
fun launchRequest()
|
||||
}
|
||||
|
||||
/** Aggregated permission states needed by the intro onboarding flow. */
|
||||
interface IntroPermissions {
|
||||
val bluetooth: IntroPermissionState
|
||||
val location: IntroPermissionState
|
||||
val notification: IntroPermissionState?
|
||||
}
|
||||
|
||||
/** Provides platform-specific permission states to the intro nav graph. */
|
||||
@Suppress("CompositionLocalAllowlist")
|
||||
val LocalIntroPermissions = staticCompositionLocalOf<IntroPermissions> { error("IntroPermissions not provided") }
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.intro
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/** Platform-agnostic navigator for opening system settings from the intro flow. */
|
||||
interface IntroSettingsNavigator {
|
||||
fun openAppSettings()
|
||||
|
||||
fun openCriticalAlertsSettings()
|
||||
}
|
||||
|
||||
/** Provides platform-specific settings navigation to the intro screens. */
|
||||
@Suppress("CompositionLocalAllowlist")
|
||||
val LocalIntroSettingsNavigator =
|
||||
staticCompositionLocalOf<IntroSettingsNavigator> { error("IntroSettingsNavigator not provided") }
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -78,7 +77,7 @@ internal fun FeatureRow(feature: FeatureUIData) {
|
||||
* @return An [AnnotatedString] with the specified portion styled and annotated.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Context.createClickableAnnotatedString(
|
||||
internal fun createClickableAnnotatedString(
|
||||
fullTextRes: StringResource,
|
||||
linkTextRes: StringResource,
|
||||
tag: String,
|
||||
@@ -16,11 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.configure_location_permissions
|
||||
import org.meshtastic.core.resources.distance_filters
|
||||
@@ -38,6 +36,7 @@ import org.meshtastic.core.resources.share_location_description
|
||||
import org.meshtastic.core.ui.icon.HardwareModel
|
||||
import org.meshtastic.core.ui.icon.LocationOn
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/**
|
||||
* Screen for configuring location permissions during the app introduction. It explains why location permissions are
|
||||
@@ -47,12 +46,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
* button.
|
||||
* @param onSkip Callback invoked if the user chooses to skip location permission setup.
|
||||
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
|
||||
* @param onOpenSettings Callback invoked when the user taps the settings link.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
internal fun LocationScreen(
|
||||
showNextButton: Boolean,
|
||||
onSkip: () -> Unit,
|
||||
onConfigure: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
val annotatedString =
|
||||
context.createClickableAnnotatedString(
|
||||
createClickableAnnotatedString(
|
||||
fullTextRes = Res.string.phone_location_description,
|
||||
linkTextRes = Res.string.settings,
|
||||
tag = SETTINGS_TAG,
|
||||
@@ -71,12 +75,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
|
||||
subtitleRes = Res.string.distance_measurements_description,
|
||||
),
|
||||
FeatureUIData(
|
||||
icon = MeshtasticIcons.HardwareModel, // Consider a different icon if appropriate
|
||||
icon = MeshtasticIcons.HardwareModel,
|
||||
titleRes = Res.string.distance_filters,
|
||||
subtitleRes = Res.string.distance_filters_description,
|
||||
),
|
||||
FeatureUIData(
|
||||
icon = MeshtasticIcons.LocationOn, // Consider a different icon if appropriate
|
||||
icon = MeshtasticIcons.LocationOn,
|
||||
titleRes = Res.string.mesh_map_location,
|
||||
subtitleRes = Res.string.mesh_map_location_description,
|
||||
),
|
||||
@@ -89,10 +93,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
|
||||
onSkip = onSkip,
|
||||
onConfigure = onConfigure,
|
||||
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_location_permissions,
|
||||
onAnnotationClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onAnnotationClick = { onOpenSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun LocationScreenPreview() {
|
||||
AppTheme { Surface { LocationScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
|
||||
}
|
||||
@@ -16,11 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_notifications
|
||||
import org.meshtastic.core.resources.configure_notification_permissions
|
||||
@@ -37,6 +35,7 @@ import org.meshtastic.core.ui.icon.BatteryAlert
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Message
|
||||
import org.meshtastic.core.ui.icon.Speaker
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/**
|
||||
* Screen for configuring notification permissions during the app introduction. It explains why notification permissions
|
||||
@@ -46,12 +45,17 @@ import org.meshtastic.core.ui.icon.Speaker
|
||||
* button.
|
||||
* @param onSkip Callback invoked if the user chooses to skip notification permission setup.
|
||||
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
|
||||
* @param onOpenSettings Callback invoked when the user taps the settings link.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
internal fun NotificationsScreen(
|
||||
showNextButton: Boolean,
|
||||
onSkip: () -> Unit,
|
||||
onConfigure: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
val annotatedString =
|
||||
context.createClickableAnnotatedString(
|
||||
createClickableAnnotatedString(
|
||||
fullTextRes = Res.string.notification_permissions_description,
|
||||
linkTextRes = Res.string.settings,
|
||||
tag = SETTINGS_TAG,
|
||||
@@ -83,10 +87,14 @@ internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, on
|
||||
onSkip = onSkip,
|
||||
onConfigure = onConfigure,
|
||||
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_notification_permissions,
|
||||
onAnnotationClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onAnnotationClick = { onOpenSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun NotificationsScreenPreview() {
|
||||
AppTheme {
|
||||
Surface { NotificationsScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) }
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,14 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -48,6 +49,7 @@ import org.meshtastic.core.ui.icon.Antenna
|
||||
import org.meshtastic.core.ui.icon.MeshHub
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.NearMe
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
|
||||
|
||||
/**
|
||||
@@ -80,11 +82,11 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
IntroBottomBar(
|
||||
onSkip = {}, // No skip on welcome
|
||||
onSkip = {},
|
||||
onConfigure = onGetStarted,
|
||||
skipButtonText = "", // Not shown
|
||||
skipButtonText = "",
|
||||
configureButtonText = stringResource(Res.string.get_started),
|
||||
showSkipButton = false, // Explicitly hide skip for welcome
|
||||
showSkipButton = false,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
@@ -114,8 +116,8 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun WelcomeScreenPreview() {
|
||||
WelcomeScreen(onGetStarted = {})
|
||||
AppTheme { Surface { WelcomeScreen(onGetStarted = {}) } }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 org.meshtastic.feature.intro
|
||||
|
||||
/** JVM/Desktop stub: permissions are always granted (desktop doesn't need BLE/location onboarding). */
|
||||
internal object JvmIntroPermissions : IntroPermissions {
|
||||
private val grantedState =
|
||||
object : IntroPermissionState {
|
||||
override val isGranted: Boolean = true
|
||||
|
||||
override fun launchRequest() = Unit
|
||||
}
|
||||
|
||||
override val bluetooth: IntroPermissionState = grantedState
|
||||
override val location: IntroPermissionState = grantedState
|
||||
override val notification: IntroPermissionState = grantedState
|
||||
}
|
||||
|
||||
/** JVM/Desktop stub: settings navigation is a no-op. */
|
||||
internal object JvmIntroSettingsNavigator : IntroSettingsNavigator {
|
||||
override fun openAppSettings() = Unit
|
||||
|
||||
override fun openCriticalAlertsSettings() = Unit
|
||||
}
|
||||
@@ -85,9 +85,13 @@
|
||||
- File: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt` (line 112)
|
||||
- Rationale: Hardcoded string is fragile; should reference the same constant used when the channel is created.
|
||||
|
||||
- [ ] **[DEFERRED]** **OB-T101**: Migrate UI screens from `androidMain` to `commonMain` using CMP-compatible permission abstraction — *Deferred: large refactor — migrate intro UI from androidMain to commonMain. Requires CMP-compatible permission abstraction design.*
|
||||
- Files: All 8 `androidMain` UI files
|
||||
- Rationale: Constitution §I requires business logic in `commonMain`. While UI screens are *not* business logic, migrating them enables Desktop/iOS compilation. Requires replacing Accompanist Permissions with a KMP-compatible permission API (e.g., interface + DI expect/actual).
|
||||
- [x] **OB-T101**: Migrate UI screens from `androidMain` to `commonMain` using CMP-compatible permission abstraction
|
||||
- Created `IntroPermissions` and `IntroSettingsNavigator` abstractions in `commonMain`
|
||||
- Moved all 8 UI files (screens, nav graph, helpers) to `commonMain`
|
||||
- Added `AndroidIntroPermissions`/`AndroidIntroSettingsNavigator` adapters in `androidMain` (wrapping Accompanist)
|
||||
- Added JVM stubs (`JvmIntroDefaults.kt`) with always-granted permissions
|
||||
- `AppIntroductionScreen` remains in `androidMain` as thin CompositionLocal provider host
|
||||
- Added CMP `@PreviewLightDark` previews for all 5 screens
|
||||
|
||||
- [ ] **[DEFERRED]** **OB-T102**: Add Compose UI tests (screenshot or interaction tests) for all 5 screens — *Deferred: requires Compose UI test infrastructure.*
|
||||
- Rationale: Only ViewModel logic is unit-tested. No UI rendering or interaction tests exist. Consider `@Preview` screenshot tests or Compose test rule tests.
|
||||
|
||||
Reference in New Issue
Block a user