diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c2007b..e1edda2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- show on boarding as a modal on large screens
+
### Fixed
- Desktop: audio playback failed with *Failed to play audio* on every record,
diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml
index 2a7a3d3..8498575 100644
--- a/data/resources/src/commonMain/composeResources/values-br/strings.xml
+++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml
@@ -42,6 +42,7 @@
Tizh boas, hep ehan etre ar frazennoù
War-lerc\'h
Kregiñ
+ Serriñ
Distreiñ
Frazenn a-raok
diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml
index f9c967d..4b415a8 100644
--- a/data/resources/src/commonMain/composeResources/values/strings.xml
+++ b/data/resources/src/commonMain/composeResources/values/strings.xml
@@ -42,6 +42,7 @@
Vitesse normale, sans pause
Suivant
Commencer
+ Fermer
Retour
Phrase précédente
diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt
index 836fe15..c88af1f 100644
--- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt
+++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt
@@ -1,20 +1,27 @@
package bzh.ajaury.chombev.onboarding.ui
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
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.runtime.getValue
@@ -23,6 +30,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
@@ -31,6 +40,7 @@ import bzh.ajaury.chombev.onboarding.ui.components.OnboardingPageIndicator
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingUiState
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.resources.generated.resources.Res
+import bzh.ajaury.chombev.resources.generated.resources.onboarding_close
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
@@ -46,93 +56,156 @@ import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
+private val DIALOG_MAX_WIDTH = 560.dp
+private val DIALOG_MAX_HEIGHT = 680.dp
+private const val DIALOG_WINDOW_FRACTION = 0.9f
+
@Composable
-fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) {
+fun OnboardingScreen(
+ showAsDialog: Boolean = false,
+ viewModel: OnboardingViewModel = koinViewModel(),
+) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
+ showAsDialog = showAsDialog,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
+ onDismiss = viewModel::skipOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
+ showAsDialog: Boolean = false,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
+ onDismiss: () -> Unit = {},
+) {
+ if (showAsDialog) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ // The content sizes itself against the window rather than the platform default width.
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(
+ modifier = Modifier
+ .sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
+ .fillMaxSize(fraction = DIALOG_WINDOW_FRACTION),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ OnboardingContent(
+ uiState = uiState,
+ onLevelSelected = onLevelSelected,
+ onFinish = onFinish,
+ onClose = onDismiss,
+ )
+ }
+ }
+ } else {
+ Scaffold { innerPadding ->
+ OnboardingContent(
+ uiState = uiState,
+ modifier = Modifier.padding(innerPadding),
+ onLevelSelected = onLevelSelected,
+ onFinish = onFinish,
+ )
+ }
+ }
+}
+
+@Composable
+private fun OnboardingContent(
+ uiState: OnboardingUiState,
+ modifier: Modifier = Modifier,
+ onLevelSelected: (OnboardingLevel) -> Unit = {},
+ onFinish: () -> Unit = {},
+ onClose: (() -> Unit)? = null,
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
- Scaffold { innerPadding ->
- Column(
- modifier = Modifier
- .padding(innerPadding)
- .fillMaxSize()
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- HorizontalPager(
- state = pagerState,
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- ) { page ->
- when (page) {
- 0 -> OnboardingInfoPage(
- icon = Icons.Filled.Hearing,
- title = stringResource(Res.string.onboarding_goal_title),
- description = stringResource(Res.string.onboarding_goal_description),
- )
-
- 1 -> OnboardingInfoPage(
- icon = Icons.Filled.Subtitles,
- title = stringResource(Res.string.onboarding_subtitles_title),
- description = stringResource(Res.string.onboarding_subtitles_description),
- )
-
- 2 -> OnboardingInfoPage(
- icon = Icons.Filled.Speed,
- title = stringResource(Res.string.onboarding_playback_title),
- description = stringResource(Res.string.onboarding_playback_description),
- )
-
- else -> OnboardingLevelPage(
- selectedLevel = uiState.selectedLevel,
- onLevelSelected = onLevelSelected,
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (onClose != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(Res.string.onboarding_close),
)
}
}
+ }
- OnboardingPageIndicator(
- pageCount = PAGE_COUNT,
- currentPage = pagerState.currentPage,
- )
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) { page ->
+ when (page) {
+ 0 -> OnboardingInfoPage(
+ icon = Icons.Filled.Hearing,
+ title = stringResource(Res.string.onboarding_goal_title),
+ description = stringResource(Res.string.onboarding_goal_description),
+ )
- Spacer(modifier = Modifier.height(24.dp))
+ 1 -> OnboardingInfoPage(
+ icon = Icons.Filled.Subtitles,
+ title = stringResource(Res.string.onboarding_subtitles_title),
+ description = stringResource(Res.string.onboarding_subtitles_description),
+ )
- Button(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- if (isLastPage) {
- onFinish()
- } else {
- scope.launch {
- pagerState.animateScrollToPage(pagerState.currentPage + 1)
- }
- }
- },
- ) {
- Text(
- text = stringResource(
- if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
- ),
+ 2 -> OnboardingInfoPage(
+ icon = Icons.Filled.Speed,
+ title = stringResource(Res.string.onboarding_playback_title),
+ description = stringResource(Res.string.onboarding_playback_description),
+ )
+
+ else -> OnboardingLevelPage(
+ selectedLevel = uiState.selectedLevel,
+ onLevelSelected = onLevelSelected,
)
}
}
+
+ OnboardingPageIndicator(
+ pageCount = PAGE_COUNT,
+ currentPage = pagerState.currentPage,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ if (isLastPage) {
+ onFinish()
+ } else {
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ }
+ },
+ ) {
+ Text(
+ text = stringResource(
+ if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
+ ),
+ )
+ }
}
}
@@ -148,3 +221,23 @@ private fun OnboardingScreenPreview() {
)
}
}
+
+@Preview
+@Composable
+private fun OnboardingContentAsDialogPreview() {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ OnboardingContent(
+ uiState = OnboardingUiState(
+ isCompleted = false,
+ selectedLevel = OnboardingLevel.BEGINNER,
+ ),
+ onClose = {},
+ )
+ }
+ }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt
index eaee4f0..1875dd6 100644
--- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt
+++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt
@@ -36,6 +36,12 @@ class OnboardingViewModel(
selectedLevel.value = level
}
+ fun skipOnboarding() {
+ viewModelScope.launch {
+ preferencesRepository.setOnboardingCompleted()
+ }
+ }
+
fun completeOnboarding() {
val level = selectedLevel.value
viewModelScope.launch {
diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt
index 76028dc..701abe1 100644
--- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt
+++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt
@@ -10,7 +10,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.rememberSerializable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.metadata
@@ -35,6 +38,9 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration
import org.koin.dsl.module
+private val WIDE_WINDOW_MIN_WIDTH = 840.dp
+private val WIDE_WINDOW_MIN_HEIGHT = 600.dp
+
val appModule = module {
includes(
loggingModule,
@@ -59,21 +65,32 @@ fun App() {
val onboardingViewModel: OnboardingViewModel = koinViewModel()
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
- when (onboardingUiState.isCompleted) {
- // The persisted flag is still being read: render nothing rather than flashing
- // either screen.
- null -> Unit
+ val showOnboardingAsDialog = isWideWindow()
- // First launch: onboarding replaces the whole UI. Completing it flips the persisted
- // flag, which switches to the main content automatically.
- false -> OnboardingScreen()
+ val isOnboardingVisible = onboardingUiState.isCompleted == false
+ val isMainContentVisible = onboardingUiState.isCompleted == true ||
+ (isOnboardingVisible && showOnboardingAsDialog)
- true -> MainNavigation(darkTheme = darkTheme)
+ if (isMainContentVisible) {
+ MainNavigation(darkTheme = darkTheme)
+ }
+
+ if (isOnboardingVisible) {
+ OnboardingScreen(showAsDialog = showOnboardingAsDialog)
}
}
}
}
+@Composable
+private fun isWideWindow(): Boolean {
+ val containerSize = LocalWindowInfo.current.containerSize
+ return with(LocalDensity.current) {
+ containerSize.width.toDp() >= WIDE_WINDOW_MIN_WIDTH &&
+ containerSize.height.toDp() >= WIDE_WINDOW_MIN_HEIGHT
+ }
+}
+
@Composable
private fun MainNavigation(darkTheme: Boolean) {
val serializer = SnapshotStateListSerializer()