feature: show onboarding as a modal on large screens, add close functionality
This commit is contained in:
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- show on boarding as a modal on large screens
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Desktop: audio playback failed with *Failed to play audio* on every record,
|
- Desktop: audio playback failed with *Failed to play audio* on every record,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
|
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
|
||||||
<string name="onboarding_next">War-lerc\'h</string>
|
<string name="onboarding_next">War-lerc\'h</string>
|
||||||
<string name="onboarding_start">Kregiñ</string>
|
<string name="onboarding_start">Kregiñ</string>
|
||||||
|
<string name="onboarding_close">Serriñ</string>
|
||||||
|
|
||||||
<string name="player_back">Distreiñ</string>
|
<string name="player_back">Distreiñ</string>
|
||||||
<string name="player_previous_sentence">Frazenn a-raok</string>
|
<string name="player_previous_sentence">Frazenn a-raok</string>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
|
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
|
||||||
<string name="onboarding_next">Suivant</string>
|
<string name="onboarding_next">Suivant</string>
|
||||||
<string name="onboarding_start">Commencer</string>
|
<string name="onboarding_start">Commencer</string>
|
||||||
|
<string name="onboarding_close">Fermer</string>
|
||||||
|
|
||||||
<string name="player_back">Retour</string>
|
<string name="player_back">Retour</string>
|
||||||
<string name="player_previous_sentence">Phrase précédente</string>
|
<string name="player_previous_sentence">Phrase précédente</string>
|
||||||
|
|||||||
+152
-59
@@ -1,20 +1,27 @@
|
|||||||
package bzh.ajaury.chombev.onboarding.ui
|
package bzh.ajaury.chombev.onboarding.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
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.Hearing
|
||||||
import androidx.compose.material.icons.filled.Speed
|
import androidx.compose.material.icons.filled.Speed
|
||||||
import androidx.compose.material.icons.filled.Subtitles
|
import androidx.compose.material.icons.filled.Subtitles
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,6 +30,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
|
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
|
||||||
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
|
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.OnboardingUiState
|
||||||
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
|
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
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_description
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
|
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
|
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 PAGE_COUNT = 4
|
||||||
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
|
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
|
@Composable
|
||||||
fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) {
|
fun OnboardingScreen(
|
||||||
|
showAsDialog: Boolean = false,
|
||||||
|
viewModel: OnboardingViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
OnboardingScreen(
|
OnboardingScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
showAsDialog = showAsDialog,
|
||||||
onLevelSelected = viewModel::selectLevel,
|
onLevelSelected = viewModel::selectLevel,
|
||||||
onFinish = viewModel::completeOnboarding,
|
onFinish = viewModel::completeOnboarding,
|
||||||
|
onDismiss = viewModel::skipOnboarding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
uiState: OnboardingUiState,
|
uiState: OnboardingUiState,
|
||||||
|
showAsDialog: Boolean = false,
|
||||||
onLevelSelected: (OnboardingLevel) -> Unit = {},
|
onLevelSelected: (OnboardingLevel) -> Unit = {},
|
||||||
onFinish: () -> 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 pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
|
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
|
||||||
|
|
||||||
Scaffold { innerPadding ->
|
Column(
|
||||||
Column(
|
modifier = modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(24.dp),
|
||||||
.fillMaxSize()
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
.padding(24.dp),
|
) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
if (onClose != null) {
|
||||||
) {
|
Row(
|
||||||
HorizontalPager(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
state = pagerState,
|
horizontalArrangement = Arrangement.End,
|
||||||
modifier = Modifier
|
) {
|
||||||
.weight(1f)
|
IconButton(onClick = onClose) {
|
||||||
.fillMaxWidth(),
|
Icon(
|
||||||
) { page ->
|
imageVector = Icons.Filled.Close,
|
||||||
when (page) {
|
contentDescription = stringResource(Res.string.onboarding_close),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OnboardingPageIndicator(
|
HorizontalPager(
|
||||||
pageCount = PAGE_COUNT,
|
state = pagerState,
|
||||||
currentPage = pagerState.currentPage,
|
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(
|
2 -> OnboardingInfoPage(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
icon = Icons.Filled.Speed,
|
||||||
onClick = {
|
title = stringResource(Res.string.onboarding_playback_title),
|
||||||
if (isLastPage) {
|
description = stringResource(Res.string.onboarding_playback_description),
|
||||||
onFinish()
|
)
|
||||||
} else {
|
|
||||||
scope.launch {
|
else -> OnboardingLevelPage(
|
||||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
selectedLevel = uiState.selectedLevel,
|
||||||
}
|
onLevelSelected = onLevelSelected,
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
@@ -36,6 +36,12 @@ class OnboardingViewModel(
|
|||||||
selectedLevel.value = level
|
selectedLevel.value = level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipOnboarding() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setOnboardingCompleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun completeOnboarding() {
|
fun completeOnboarding() {
|
||||||
val level = selectedLevel.value
|
val level = selectedLevel.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.saveable.rememberSerializable
|
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.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.metadata
|
import androidx.navigation3.runtime.metadata
|
||||||
@@ -35,6 +38,9 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
import org.koin.dsl.koinConfiguration
|
import org.koin.dsl.koinConfiguration
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
private val WIDE_WINDOW_MIN_WIDTH = 840.dp
|
||||||
|
private val WIDE_WINDOW_MIN_HEIGHT = 600.dp
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
includes(
|
includes(
|
||||||
loggingModule,
|
loggingModule,
|
||||||
@@ -59,21 +65,32 @@ fun App() {
|
|||||||
val onboardingViewModel: OnboardingViewModel = koinViewModel()
|
val onboardingViewModel: OnboardingViewModel = koinViewModel()
|
||||||
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
|
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
when (onboardingUiState.isCompleted) {
|
val showOnboardingAsDialog = isWideWindow()
|
||||||
// The persisted flag is still being read: render nothing rather than flashing
|
|
||||||
// either screen.
|
|
||||||
null -> Unit
|
|
||||||
|
|
||||||
// First launch: onboarding replaces the whole UI. Completing it flips the persisted
|
val isOnboardingVisible = onboardingUiState.isCompleted == false
|
||||||
// flag, which switches to the main content automatically.
|
val isMainContentVisible = onboardingUiState.isCompleted == true ||
|
||||||
false -> OnboardingScreen()
|
(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
|
@Composable
|
||||||
private fun MainNavigation(darkTheme: Boolean) {
|
private fun MainNavigation(darkTheme: Boolean) {
|
||||||
val serializer = SnapshotStateListSerializer<Route>()
|
val serializer = SnapshotStateListSerializer<Route>()
|
||||||
|
|||||||
Reference in New Issue
Block a user