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