From cce3032e0c0710ec306f497aa6b6c4dfd4ada5e2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 11 May 2026 14:36:03 -0500 Subject: [PATCH] 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> --- .agent_memory/session_context.md | 5 ++ .../feature/intro/AndroidIntroPermissions.kt | 55 +++++++++++++++++ .../intro/AndroidIntroSettingsNavigator.kt | 42 +++++++++++++ .../feature/intro/AppIntroductionScreen.kt | 35 +++++++---- .../feature/intro/BluetoothScreen.kt | 30 ++++++---- .../feature/intro/CriticalAlertsScreen.kt | 9 +++ .../meshtastic/feature/intro/FeatureUIData.kt | 0 .../feature/intro/IntroBottomBar.kt | 0 .../meshtastic/feature/intro/IntroNavGraph.kt | 59 ++++++++----------- .../feature/intro/IntroPermissions.kt | 37 ++++++++++++ .../feature/intro/IntroSettingsNavigator.kt | 31 ++++++++++ .../feature/intro/IntroUiHelpers.kt | 3 +- .../feature/intro/LocationScreen.kt | 34 ++++++----- .../feature/intro/NotificationsScreen.kt | 32 ++++++---- .../feature/intro/PermissionScreenLayout.kt | 0 .../meshtastic/feature/intro/WelcomeScreen.kt | 14 +++-- .../feature/intro/JvmIntroDefaults.kt | 38 ++++++++++++ specs/010-onboarding/tasks.md | 10 +++- 18 files changed, 337 insertions(+), 97 deletions(-) create mode 100644 feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt create mode 100644 feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroSettingsNavigator.kt rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt (82%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt (91%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/FeatureUIData.kt (100%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/IntroBottomBar.kt (100%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt (56%) create mode 100644 feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt create mode 100644 feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroSettingsNavigator.kt rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt (97%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/LocationScreen.kt (82%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt (83%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/PermissionScreenLayout.kt (100%) rename feature/intro/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt (93%) create mode 100644 feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index bed355d44..f1a5b4ebc 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,11 @@ # Do NOT edit or remove previous entries — stale state claims cause agent confusion. # Format: ## YYYY-MM-DD — +## 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. diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt new file mode 100644 index 000000000..282f42095 --- /dev/null +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt @@ -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 . + */ +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() + } + } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroSettingsNavigator.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroSettingsNavigator.kt new file mode 100644 index 000000000..33ef7f3a2 --- /dev/null +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroSettingsNavigator.kt @@ -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 . + */ +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) + } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt index 0e1e5735c..1e2d1d0e7 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt @@ -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) }, + ) + } } diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt similarity index 82% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt index eb3b9ef3f..278e2bb24 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt @@ -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 = {}) } } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt similarity index 91% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt index 19100fb89..0bb2bed5b 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/CriticalAlertsScreen.kt @@ -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 = {}) } } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/FeatureUIData.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/FeatureUIData.kt similarity index 100% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/FeatureUIData.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/FeatureUIData.kt diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroBottomBar.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroBottomBar.kt similarity index 100% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroBottomBar.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroBottomBar.kt diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt similarity index 56% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt index 78da0ac06..47e163f1e 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt @@ -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.introGraph( backStack: NavBackStack, 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 { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) } entry { - 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 { - 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 { - 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 { + 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() }, ) diff --git a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt new file mode 100644 index 000000000..b13ecd5ec --- /dev/null +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt @@ -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 . + */ +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 { error("IntroPermissions not provided") } diff --git a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroSettingsNavigator.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroSettingsNavigator.kt new file mode 100644 index 000000000..9aa60bf4c --- /dev/null +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroSettingsNavigator.kt @@ -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 . + */ +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 { error("IntroSettingsNavigator not provided") } diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt similarity index 97% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt index 701c7e222..f7ae2703c 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroUiHelpers.kt @@ -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, diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt similarity index 82% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt index bb96b2b6a..cdee1a57e 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/LocationScreen.kt @@ -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 = {}) } } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt similarity index 83% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt index 1788b5530..bbe7e76a7 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt @@ -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 = {}) } + } +} diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/PermissionScreenLayout.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/PermissionScreenLayout.kt similarity index 100% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/PermissionScreenLayout.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/PermissionScreenLayout.kt diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt similarity index 93% rename from feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt rename to feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt index ed619bf8e..c3f66e4b2 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt +++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt @@ -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 = {}) } } } diff --git a/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt b/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt new file mode 100644 index 000000000..0d5e9c22e --- /dev/null +++ b/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt @@ -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 . + */ +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 +} diff --git a/specs/010-onboarding/tasks.md b/specs/010-onboarding/tasks.md index cc58ffe59..66cd8d799 100644 --- a/specs/010-onboarding/tasks.md +++ b/specs/010-onboarding/tasks.md @@ -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.