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:
James Rich
2026-05-11 14:36:03 -05:00
parent 68700f9add
commit cce3032e0c
18 changed files with 337 additions and 97 deletions

View File

@@ -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.

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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) },
)
}
}

View File

@@ -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 = {}) } }
}

View File

@@ -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 = {}) } }
}

View File

@@ -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()
},
)

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 = {}) } }
}

View File

@@ -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 = {}) }
}
}

View File

@@ -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 = {}) } }
}

View File

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

View File

@@ -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.