diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index a0e7c4a..3539265 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - "dev" + - "releases/**" jobs: static-analysis-check: name: Static code analysis diff --git a/TestInstructions.md b/TestInstructions.md new file mode 100644 index 0000000..e125d9c --- /dev/null +++ b/TestInstructions.md @@ -0,0 +1,26 @@ +# Lifecycle testing + +When you do any changes in the internal logic, that can affect the lifecycle of the screen, you should manually test correctness of the lifecycle in +certain cases: + +* Simple forward, back, replace commands +* Adding and removing container screens with nested screens + * Adding container screen with 1 screen and removing it + * Adding container screen with multiple screens and removing it + * Check same in `LazyList` +* Check correctness, with animation interruption. F.e. 2 fast back clicks + * Check pause, stop. dispose - 2 fast back or replace + * Check create, start, resume - 2 fast forward or replace +* Movable content test +* Check lifecycle order in nested screens when activity/fragment recreated, by rotating the screen. Following rules should be applied: + * Parent screens events ON_CREATE, ON_START, ON_RESUME should be called before child screens events + * Parent screens events ON_PAUSE, ON_STOP, ON_DESTROY should be called after child screens events + +## Test cases + +### Removing previous screen + +1. Use `LifecycleScreenEffect` to subscribe to lifecycle updates in `ButtonsScreenContent` (remove comments from code). +2. Launch app and navigate to "Stack Actions". +3. Click "Remove previous". +4. "LifecycleScreenEffect ON_DESTROY" in logcat for previous screen. \ No newline at end of file diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index eecca4e..774d9de 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -707,6 +707,7 @@ style: ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true + ignoreAnnotated: ['Preview'] MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: @@ -882,7 +883,7 @@ Compose: CompositionLocalAllowlist: active: true # -- You can optionally define a list of CompositionLocals that are allowed here - allowedCompositionLocals: LocalContainerScreen,LocalMultiScreenNavigation,LocalSaveableStateHolder,LocalTransitionCompleteChannel,LocalStackNavigation, + allowedCompositionLocals: LocalContainerScreen,LocalMultiScreenNavigation,LocalSaveableStateHolder,LocalBeforeScreenContentOnDispose,LocalAfterScreenContentOnDispose,LocalStackNavigation, CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6dd346..8a34866 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] +composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" -modo = "0.9.0" +modo = "0.10.0" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" @@ -18,6 +19,7 @@ kotlin = "1.9.23" kotlinCompilerExtension = "1.5.12" minSdk = "21" compileSdk = "34" +koin = "4.0.0" [libraries] androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" } @@ -38,6 +40,7 @@ androidx-lifecycle-runtimeKtx = { group = "androidx.lifecycle", name = "lifecycl androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version = "1.6.2" } +compose-wheelPicker = { module = "com.github.zj565061763:compose-wheel-picker", version.ref = "composeWheelPicker" } detekt-composeRules = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektComposeVersion" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektVersion" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } @@ -45,6 +48,8 @@ test-junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", vers debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.8.1" } + # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" } @@ -52,6 +57,8 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } detektPlugin = { group = "io.gitlab.arturbosch.detekt", name = "detekt-gradle-plugin", version.ref = "detektVersion" } +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } [plugins] modo-detekt = { id = "modo-detekt", version = "unspecified" } modo-android-library = { id = "modo-android-library", version = "unspecified" } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 8f8683c..2aa67d0 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -3,18 +3,27 @@ package com.github.terrakok.modo import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP import com.github.terrakok.modo.android.ModoScreenAndroidAdapter -import com.github.terrakok.modo.animation.displayingScreens +import com.github.terrakok.modo.animation.ScreenTransition +import com.github.terrakok.modo.animation.displayingScreensAfterScreenContent +import com.github.terrakok.modo.animation.displayingScreensBeforeScreenContent +import com.github.terrakok.modo.lifecycle.LifecycleDependency import com.github.terrakok.modo.model.ScreenModelStore +import com.github.terrakok.modo.model.dependenciesSortedByRemovePriority import com.github.terrakok.modo.util.currentOrThrow import kotlinx.coroutines.channels.Channel @@ -22,36 +31,74 @@ typealias RendererContent = @Composable ComposeRendererScope.(Modi val defaultRendererContent: (@Composable ComposeRendererScope<*>.(screenModifier: Modifier) -> Unit) = { screenModifier -> screen.SaveableContent(screenModifier) - val channel = LocalTransitionCompleteChannel.current - // There's no animation, we can instantly mark the transition as completed - DisposableEffect(screen.screenKey) { - onDispose { - channel.trySend(Unit) - } - } } val LocalSaveableStateHolder = staticCompositionLocalOf { null } +private val LocalBeforeScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { + error("No LocalBeforeScreenContentOnDispose provided!") +} + +private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { + error("No LocalAfterScreenContentOnDispose provided!") +} + /** - * You can receive channel from it to inform Modo that your custom [ContainerScreen] finished transition and screen can be safely removed. + * Provides integration of [Screen] to Modo's navigation system: + * 1. Adds support of [rememberSaveable] by using [SaveableStateHolder.SaveableStateProvider] to store [Screen]'s state. + * 2. Adds support of Android-related features, such as ViewModel, LifeCycle and SavedStateHandle. + * 3. Handles lifecycle of [Screen] by adding [DisposableEffect] before and after content, in order to notify [ComposeRenderer] + * when [Screen.Content] is about to leave composition and when it has left composition. + * @param modifier is a modifier that will be passed into [Screen.Content] + * @param manualResumePause define whenever we are going to manually call [LifecycleDependency.onResume] and [LifecycleDependency.onPause] + * to emmit [ON_RESUME] and [ON_PAUSE]. Otherwise, [ON_RESUME] will be called straight after [ON_START] and [ON_PAUSE] will be called straight + * before [ON_STOP]. + * + * F.e. it is used by [ScreenTransition]: + * + [ON_RESUME] emitted when animation of showing screen is finished + * + [ON_PAUSE] emitted when animation of hiding screen is started */ -val LocalTransitionCompleteChannel = staticCompositionLocalOf> { error("no channel provided") } - @Composable -fun Screen.SaveableContent(modifier: Modifier = Modifier) { +fun Screen.SaveableContent( + modifier: Modifier = Modifier, + manualResumePause: Boolean = false +) { LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = screenKey) { - ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration { - DisposableEffect(this@SaveableContent) { - // For debugging -// Log.d("SaveableContent", "put screen $screenKey") - displayingScreens[this@SaveableContent] = Unit - onDispose { -// Log.d("SaveableContent", "remove screen $screenKey") - displayingScreens -= this@SaveableContent - } - } + ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(manualResumePause) { + BeforeScreenContent() Content(modifier) + AfterScreenContent() + } + } +} + +/** + * This function responsible for correct cleaning [Screen]'s when it already has left composition and some clean up can be made. + * It's not always clean [Screen] when it lives composition, but adds extra logic of tracking displaying [Screen]'s and triggering + * function provided by [LocalBeforeScreenContentOnDispose] in order to try clean removed screens that are not visible for user. + */ +@Composable +private inline fun Screen.BeforeScreenContent() { + val onDisposed = LocalBeforeScreenContentOnDispose.current + DisposableEffect(this) { + displayingScreensBeforeScreenContent[this@BeforeScreenContent] = Unit + onDispose { + displayingScreensBeforeScreenContent -= this@BeforeScreenContent +// Log.d("LifecycleDebug", "BeforeScreenContent $screenKey onDispose") + onDisposed.invoke() + } + } +} + +@Composable +private inline fun Screen.AfterScreenContent() { + val onPreDispose = LocalAfterScreenContentOnDispose.current + DisposableEffect(this) { + displayingScreensAfterScreenContent[this@AfterScreenContent] = Unit + onDispose { + displayingScreensAfterScreenContent -= this@AfterScreenContent +// Log.d("LifecycleDebug", "AfterScreenContent $screenKey onDispose") + onPreDispose() } } } @@ -81,7 +128,7 @@ internal class ComposeRenderer( var state: State? by mutableStateOf(null, neverEqualPolicy()) private set - // TODO: share removed screen for whole structure + // TODO: share removed screen for whole structure? private val removedScreens = mutableSetOf() override fun render(state: State) { @@ -90,6 +137,9 @@ internal class ComposeRenderer( } lastState = this.state this.state = state + // Handling a case when updating state doesn't cause UI to update. But if some screens was removed, we need to move them to destroy state. + // F.e. removing previous screen causes this case. + onPreDispose() } @Suppress("UnusedPrivateProperty", "SpreadOperator") @@ -101,24 +151,25 @@ internal class ComposeRenderer( content: RendererContent = defaultRendererContent ) { val stateHolder: SaveableStateHolder = LocalSaveableStateHolder.currentOrThrow - // For cases when content lives composition and LaunchedEffect doesn't handle event. - // You can reproduce it by creating custom dialog and pressing back button. - // Without this DisposableEffect you will not receive onDestroy events and won't clear screen model store. - DisposableEffect(Unit) { - onDispose { -// Log.d("ComposeRenderer", "DisposableEffect ${screen.screenKey}") + + val beforeScreenContentOnDispose = remember { + { clearScreens(stateHolder) } } - LaunchedEffect(screen.screenKey) { - for (event in transitionCompleteChannel) { -// Log.d("ComposeRenderer", "LaunchedEffect ${screen.screenKey}") - clearScreens(stateHolder) + + // pre dispose means that we can send ON_DISPOSE if screen is removing, + // to let Screen.Content to handle ON_DISPOSE by using functions like DisposableEffect + val afterScreenContentOnDispose = remember { + { + onPreDispose() } } + CompositionLocalProvider( LocalContainerScreen provides containerScreen, - LocalTransitionCompleteChannel provides transitionCompleteChannel, + LocalBeforeScreenContentOnDispose provides beforeScreenContentOnDispose, + LocalAfterScreenContentOnDispose provides afterScreenContentOnDispose, *provideCompositionLocal ) { ComposeRendererScope(lastState, state, screen).content(modifier) @@ -131,12 +182,16 @@ internal class ComposeRenderer( * @param clearAll - forces to remove all screen states that renderer holds (removed and "displayed") */ private fun clearScreens(stateHolder: SaveableStateHolder, clearAll: Boolean = false) { + fun Iterable.clearStates(stateHolder: SaveableStateHolder) = forEach { screen -> + screen.clearState(stateHolder) + } + if (clearAll) { state?.getChildScreens()?.clearStates(stateHolder) } // There can be several transition of different screens on the screen, // so it is important properly clear screens that are not visible for user. - val safeToRemove = removedScreens.filter { it !in displayingScreens } + val safeToRemove = removedScreens.filter { it !in displayingScreensBeforeScreenContent } safeToRemove.clearStates(stateHolder) if (removedScreens.isNotEmpty()) { safeToRemove.forEach { @@ -145,13 +200,29 @@ internal class ComposeRenderer( } } - private fun Iterable.clearStates(stateHolder: SaveableStateHolder) = forEach { screen -> - screen.clearState(stateHolder) + /** + * Called onPreDispose for removed screens, that are not presented in [displayingScreensAfterScreenContent] (not displayed on screen). + * @param clearAll - forces to call onPreDispose on all children screen states that renderer holds (removed and "displayed") + */ + private fun onPreDispose(clearAll: Boolean = false) { + fun Iterable.onPreDispose() = forEach { screen -> + screen.onPreDispose() + } + + if (clearAll) { + state?.getChildScreens()?.onPreDispose() + } + // There can be several transition of different screens on the screen, + // so it is important properly clear screens that are not visible for user. + val safeToRemove = removedScreens.filter { it !in displayingScreensAfterScreenContent } + safeToRemove.onPreDispose() } private fun Screen.clearState(stateHolder: SaveableStateHolder) { - if (this in displayingScreens) { - ModoDevOptions.onIllegalScreenModelStoreAccess.validationFailed( + // It's important to do this check for debug purpose, because we must guaranty that Screen is cleaned only if it is not displaying anymore. + // But it seems like it is not working with movable content, so this one is going to be triggered. + if (this in displayingScreensBeforeScreenContent) { + ModoDevOptions.onIllegalClearState.validationFailed( IllegalStateException( "Trying to remove clean state of the screen $this, why this screen still is visible for User." ) @@ -163,6 +234,16 @@ internal class ComposeRenderer( ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true) } + // need for correct handling lifecycle + private fun Screen.onPreDispose() { +// Log.d("LifecycleDebug", "afterScreenContentOnDispose $screenKey") + dependenciesSortedByRemovePriority() + .filterIsInstance() + .forEach { it.onPreDispose() } + // send afterScreenContentOnDispose to nested screens + ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.onPreDispose(clearAll = true) + } + private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List { val newChainSet = newState.getChildScreens() return oldState.getChildScreens().filter { it !in newChainSet } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt index 9ebce19..66ca74d 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt @@ -9,6 +9,10 @@ object ModoDevOptions { Log.e("Modo", "Modo internal error", throwable) } + var onIllegalClearState: ValidationFailedStrategy = ValidationFailedStrategy { throwable -> + Log.e("Modo", "Modo internal error", throwable) + } + internal const val REPORT_ISSUE_URL = "You can report issue here https://github.com/terrakok/Modo/issues" fun interface ValidationFailedStrategy { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index 2bc405c..b3e76af 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -2,6 +2,12 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -38,6 +44,15 @@ interface NavigationContainer> NavigationContainer.navigationStateFlow(): Flow = + snapshotFlow { navigationState } + +fun > NavigationContainer.navigationStateStateFlow( + coroutineScope: CoroutineScope, +): StateFlow = + snapshotFlow { navigationState } + .stateIn(coroutineScope, started = SharingStarted.WhileSubscribed(), initialValue = navigationState) + interface NavigationRenderer { fun render(state: State) } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 5ca15da..4716b8d 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -18,6 +18,12 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSavedStateRegistryOwner import androidx.lifecycle.HasDefaultViewModelProviderFactory import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -36,22 +42,30 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.lifecycle.LifecycleDependency import com.github.terrakok.modo.model.ScreenModelStore +import com.github.terrakok.modo.model.ScreenModelStore.remove import com.github.terrakok.modo.util.getActivity import com.github.terrakok.modo.util.getApplication import java.util.concurrent.atomic.AtomicReference /** - * Adapter to link modo with android. It the single instance of [ModoScreenAndroidAdapter] per Screen. + * Adapter for Screem, to support android-related features using Modo, such as: + * 1. ViewModel support + * 2. Lifecycle support + * 3. SavedState support + * + * It the single instance of [ModoScreenAndroidAdapter] per Screen. */ class ModoScreenAndroidAdapter private constructor( // just for debug purpose. -// internal val screen: Screen + internal val screen: Screen ) : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner, - HasDefaultViewModelProviderFactory { + HasDefaultViewModelProviderFactory, + LifecycleDependency { override val lifecycle: LifecycleRegistry = LifecycleRegistry(this) @@ -91,46 +105,52 @@ class ModoScreenAndroidAdapter private constructor( enableSavedStateHandles() } - private fun onCreate(savedState: Bundle?) { - check(!isCreated) { "onCreate already called" } - isCreated = true - controller.performRestore(savedState) - initEvents.forEach { - lifecycle.safeHandleLifecycleEvent(it) + @Composable + fun ProvideAndroidIntegration( + manualResumePause: Boolean = false, + content: @Composable () -> Unit, + ) { + val context: Context = LocalContext.current + val parentLifecycleOwner = LocalLifecycleOwner.current + LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) { + @Suppress("SpreadOperator") + CompositionLocalProvider(*getProviders()) { + content() + } } } - private fun emitOnStartEvents() { - startEvents.forEach { - lifecycle.safeHandleLifecycleEvent(it) - } + /** + * Must be called before [remove] to inform that this screen is going to be removed. + * We need it to provide support of using DisposableEffect or/and LaunchedEffect inside [Screen.Content]. + * F.e. to be able to collect ON_DISPOSE lifecycle event. + */ + override fun onPreDispose() { +// Log.d("LifecycleDebug", "${screen.screenKey} ModoScreenAndroidAdapter.onPreDispose, emit ON_DESTROY event.") + safeHandleLifecycleEvent(ON_DESTROY) } - private fun emitOnStopEvents() { - stopEvents.forEach { - lifecycle.safeHandleLifecycleEvent(it) - } + override fun onPause() { + safeHandleLifecycleEvent(ON_PAUSE) } - @Composable - fun ProvideAndroidIntegration( - content: @Composable () -> Unit - ) { - val context: Context = LocalContext.current - val parentLifecycleOwner = LocalLifecycleOwner.current - LifecycleDisposableEffect(context, parentLifecycleOwner) - @Suppress("SpreadOperator") - CompositionLocalProvider(*getProviders()) { - content() - } + override fun onResume() { + safeHandleLifecycleEvent(ON_RESUME) } + override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" + @Suppress("UnusedParameter") - fun onDispose(screen: Screen) { + private fun onDispose() { +// Log.d("LifecycleDebug", "${screen.screenKey} ModoScreenAndroidAdapter.onDispose. Clear ViewModelStore.") viewModelStore.clear() - disposeEvents.forEach { event -> - lifecycle.safeHandleLifecycleEvent(event) - } + } + + private fun onCreate(savedState: Bundle?) { + check(!isCreated) { "onCreate already called" } + isCreated = true + controller.performRestore(savedState) + safeHandleLifecycleEvent(ON_CREATE) } private fun performSave(outState: Bundle) { @@ -188,6 +208,8 @@ class ModoScreenAndroidAdapter private constructor( private fun LifecycleDisposableEffect( context: Context, parentLifecycleOwner: LifecycleOwner, + manualResumePause: Boolean, + content: @Composable () -> Unit ) { val activity = remember(context) { context.getActivity() @@ -197,85 +219,112 @@ class ModoScreenAndroidAdapter private constructor( onCreate(savedState) // do this in the UI thread to force it to be called before anything else } + DisposableEffect(this) { + safeHandleLifecycleEvent(ON_START) + if (!manualResumePause) { + safeHandleLifecycleEvent(ON_RESUME) + } + onDispose { } + } + + content() + DisposableEffect(this) { val unregisterLifecycle = registerParentLifecycleListener(parentLifecycleOwner) { - LifecycleEventObserver { owner, event -> - when { - /** - * Instance of the screen isn't recreated during config changes so skip this event - * to avoid crash while accessing to ViewModel with SavedStateHandle, because after - * ON_DESTROY, [androidx.lifecycle.SavedStateHandleController] is marked as not - * attached and next call of [registerSavedStateProvider] after recreating Activity - * on the same instance causing the crash. - * - * Also when activity is destroyed, but not finished, screen is not destroyed. - * - * In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this. - */ - event == Lifecycle.Event.ON_DESTROY && (activity?.isFinishing == false || activity?.isChangingConfigurations == true) -> - return@LifecycleEventObserver - // when the Application goes to background, perform save - event == Lifecycle.Event.ON_STOP -> - performSave(savedState) + LifecycleEventObserver { _, event -> + // when the Application goes to background, perform save + if (event == ON_STOP) { + performSave(savedState) + } + if ( + needPropagateLifecycleEventFromParent( + event, + isActivityFinishing = activity?.isFinishing, + isChangingConfigurations = activity?.isChangingConfigurations + ) + ) { + safeHandleLifecycleEvent(event) } - lifecycle.safeHandleLifecycleEvent(event) } } - emitOnStartEvents() - onDispose { +// Log.d("LifecycleDebug", "ModoScreenAndroidAdapter registerParentLifecycleListener onDispose ${screen.screenKey}") unregisterLifecycle() // when the screen goes to stack, perform save performSave(savedState) // notify lifecycle screen listeners - emitOnStopEvents() + if (!manualResumePause) { + safeHandleLifecycleEvent(ON_PAUSE) + } + safeHandleLifecycleEvent(ON_STOP) } } } - private fun LifecycleRegistry.safeHandleLifecycleEvent(event: Lifecycle.Event) { - val currentState = currentState - val skippEvent = !currentState.isAtLeast(Lifecycle.State.INITIALIZED) || - // Protection from double event sending from the parent - ((event in startEvents || event in initEvents) && event.targetState <= currentState) || - (event in stopEvents && event.targetState >= currentState) - - // For debugging -// Log.d("ModoScreenAndroidAdapter", "safeHandleLifecycleEvent ${screen.screenKey} $event") + private fun safeHandleLifecycleEvent(event: Lifecycle.Event) { + val skippEvent = needSkipEvent(lifecycle.currentState, event) if (!skippEvent) { - handleLifecycleEvent(event) +// Log.d("ModoScreenAndroidAdapter", "${screen.screenKey} handleLifecycleEvent $event") + lifecycle.handleLifecycleEvent(event) } } companion object { - private val initEvents = arrayOf( - Lifecycle.Event.ON_CREATE + private val moveLifecycleStateUpEvents = setOf( + ON_CREATE, + ON_START, + ON_RESUME ) - private val startEvents = arrayOf( - Lifecycle.Event.ON_START, - Lifecycle.Event.ON_RESUME - ) - - private val stopEvents = arrayOf( - Lifecycle.Event.ON_PAUSE, - Lifecycle.Event.ON_STOP - ) - - private val disposeEvents = arrayOf( - Lifecycle.Event.ON_DESTROY + private val moveLifecycleStateDownEvents = setOf( + ON_STOP, + ON_PAUSE, + ON_DESTROY ) /** * Creates delegate for integration with android for the given [screen] or returns existed from cache. */ + @JvmStatic fun get(screen: Screen): ModoScreenAndroidAdapter = ScreenModelStore.getOrPutDependency( screen = screen, - name = "AndroidScreenLifecycleOwner", - onDispose = { it.onDispose(screen) } - ) { ModoScreenAndroidAdapter() } + name = LifecycleDependency.KEY, + onDispose = { it.onDispose() }, + ) { ModoScreenAndroidAdapter(screen) } + + @JvmStatic + fun needPropagateLifecycleEventFromParent( + event: Lifecycle.Event, + isActivityFinishing: Boolean?, + isChangingConfigurations: Boolean? + ) = + /* + * Instance of the screen isn't recreated during config changes so skip this event + * to avoid crash while accessing to ViewModel with SavedStateHandle, because after + * ON_DESTROY, [androidx.lifecycle.SavedStateHandleController] is marked as not + * attached and next call of [registerSavedStateProvider] after recreating Activity + * on the same instance causing the crash. + * + * Also, when activity is destroyed, but not finished, screen is not destroyed. + * + * In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this. + */ + if (event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true)) { + false + } else { + // Parent can only move lifecycle state down. Because parent cant be already resumed, but child is not, because of running animation. + event !in moveLifecycleStateUpEvents + } + + @JvmStatic + internal fun needSkipEvent(currentState: Lifecycle.State, event: Lifecycle.Event) = + !currentState.isAtLeast(Lifecycle.State.INITIALIZED) || + // Skipping events that moves lifecycle state up, but this state is already reached. + (event in moveLifecycleStateUpEvents && event.targetState <= currentState) || + // Skipping events that moves lifecycle state down, but this state is already reached. + (event in moveLifecycleStateDownEvents && event.targetState >= currentState) } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt new file mode 100644 index 0000000..6ce9fa7 --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt @@ -0,0 +1,49 @@ +package com.github.terrakok.modo.animation + +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Left +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Right +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import com.github.terrakok.modo.ComposeRendererScope +import com.github.terrakok.modo.SaveableContent +import com.github.terrakok.modo.multiscreen.MultiScreenState + +@Composable +fun ComposeRendererScope.SlideTransition( + modifier: Modifier = Modifier, + screenModifier: Modifier = Modifier, + slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), + fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), + content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } +) { + ScreenTransition( + modifier = modifier, + screenModifier = modifier, + transitionSpec = { + if (oldState != null && newState != null) { + if (oldState.selected != newState.selected) { + if (oldState.selected < newState.selected) { + slideIntoContainer(Left, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(Left, animationSpec = slideAnimationSpec) + } else { + slideIntoContainer(Right, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(Right, animationSpec = slideAnimationSpec) + } + } else { + fadeIn(fadeAnimationSpec) togetherWith fadeOut(fadeAnimationSpec) + } + } else { + EnterTransition.None togetherWith ExitTransition.None + } + }, + content = content + ) +} \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt index bd3a65e..9e30b45 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt @@ -11,20 +11,32 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.Modifier import com.github.terrakok.modo.ComposeRendererScope -import com.github.terrakok.modo.LocalTransitionCompleteChannel import com.github.terrakok.modo.SaveableContent import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.model.lifecycleDependency -val displayingScreens = mutableStateMapOf() +val displayingScreensBeforeScreenContent = mutableStateMapOf() +val displayingScreensAfterScreenContent = mutableStateMapOf() typealias ScreenTransitionContent = @Composable AnimatedVisibilityScope.(Screen) -> Unit /** * The way to animate [Screen]'s changing (transition). + * + * It brings changes to the lifecycle of animated the [Screen]: + * * ON_RESUME - when the screen animation is finished and the screen is displayed + * * ON_PAUSE - when the screen animation is started and the screen is going to be hidden + * + * @param modifier - the modifier for the [AnimatedContent]. + * @param screenModifier - the modifier for the [Screen.Content]. + * @param transitionSpec - the transition spec for the [AnimatedContent]. + * @param content - the content that is going to be placed inside [AnimatedContent]. + * You can use it to decorate or customize the content, + * but you must apparently use [SaveableContent] with [manualResumePause] = true to guarantee that the lifecycle will be paused and resumed correctly. */ @Suppress("MagicNumber") @Composable @@ -36,19 +48,33 @@ fun ComposeRendererScope<*>.ScreenTransition( scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) togetherWith fadeOut(animationSpec = tween(90)) }, - content: ScreenTransitionContent = { it.SaveableContent(screenModifier) } + content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } ) { val transition = updateTransition(targetState = screen, label = "ScreenTransition") transition.AnimatedContent( transitionSpec = transitionSpec, modifier = modifier, contentKey = { it.screenKey }, - content = content - ) - if (transition.currentState == transition.targetState) { - val channel = LocalTransitionCompleteChannel.current - LaunchedEffect(Unit) { - channel.trySend(Unit) + ) { screen -> + DisposableEffect(transition.currentState, transition.targetState) { +// Log.d( +// "LifecycleDebug", +// "target = ${screen.screenKey}, " + +// "transition.currentState = ${transition.currentState.screenKey}," + +// "transition.targetState = ${transition.targetState.screenKey}" +// ) + if (screen == transition.currentState && screen != transition.targetState) { + // Start of animation that hides this screen, so we should pause lifecycle +// Log.d("LifecycleDebug", "${screen.screenKey}: ON_PAUSE!") + screen.lifecycleDependency()?.onPause() + } + if (transition.currentState == transition.targetState && screen == transition.currentState) { + // Finish of animation that shows this screen, so we should resume lifecycle +// Log.d("LifecycleDebug", "${screen.screenKey}: ON_RESUME!") + screen.lifecycleDependency()?.onResume() + } + onDispose { } } + content(screen) } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt new file mode 100644 index 0000000..90b095e --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt @@ -0,0 +1,68 @@ +package com.github.terrakok.modo.animation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Down +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Left +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Right +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Up +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import com.github.terrakok.modo.ComposeRendererScope +import com.github.terrakok.modo.DialogScreen +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.SaveableContent +import com.github.terrakok.modo.stack.StackState + +@Composable +@OptIn(ExperimentalModoApi::class) +fun ComposeRendererScope.SlideTransition( + modifier: Modifier = Modifier, + screenModifier: Modifier = Modifier, + pushDirection: AnimatedContentTransitionScope.SlideDirection = Left, + popDirection: AnimatedContentTransitionScope.SlideDirection = pushDirection.opposite(), + slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), + fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), + content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } +) { + ScreenTransition( + modifier = modifier, + screenModifier = screenModifier, + transitionSpec = { + val transitionType: StackTransitionType = calculateStackTransitionType() + when { + transitionType == StackTransitionType.Replace || + oldState?.stack?.last() is DialogScreen || + newState?.stack?.last() is DialogScreen -> { + fadeIn(fadeAnimationSpec) togetherWith fadeOut(fadeAnimationSpec) + } + else -> { + when (transitionType) { + StackTransitionType.Push -> slideIntoContainer(pushDirection, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(pushDirection, animationSpec = slideAnimationSpec) + else -> slideIntoContainer(popDirection, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(popDirection, animationSpec = slideAnimationSpec) + } + } + } + }, + content = content + ) +} + +private fun AnimatedContentTransitionScope.SlideDirection.opposite() = when (this) { + Left -> Right + Right -> Left + Up -> Down + Down -> Up + Start -> End + End -> Up + else -> error("Unknown direction $this") +} \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt b/modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt new file mode 100644 index 0000000..6cc9c6f --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt @@ -0,0 +1,28 @@ +package com.github.terrakok.modo.lifecycle + +import com.github.terrakok.modo.Screen + +/** + * Interface for lifecycle dependent objects, on witch we want to call [onPreDispose] just before actual removal of dependency and screen, + * to be able to send ON_DISPOSE event and collect it in [Screen.Content]. + */ +interface LifecycleDependency { + + /** + * Should be called when associated screen is ready to be moved to ON_RESUME state. + * F.e. when screen appearance animation is finished and screen is fully visible. + */ + fun onResume() + + /** + * Should be called when associated screen is ready to be moved to ON_PAUSE state. + * F.e. when screen hide animation is started and screen is not fully visible. + */ + fun onPause() + + fun onPreDispose() + + companion object { + const val KEY = "LifecycleDependency" + } +} \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt index d92249f..1472a40 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt @@ -141,5 +141,4 @@ fun NavigationContainer.setScreens(va dispatch(ListNavigationAction.SetScreens(*screens)) fun NavigationContainer.removeAllScreens() = - dispatch(ListNavigationAction.SetScreens()) - + dispatch(ListNavigationAction.SetScreens()) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt new file mode 100644 index 0000000..27eb9bb --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt @@ -0,0 +1,10 @@ +package com.github.terrakok.modo.model + +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.lifecycle.LifecycleDependency + +fun Screen.dependenciesSortedByRemovePriority(): Sequence = + ScreenModelStore.screenDependenciesSortedByRemovePriority(this) + +fun Screen.lifecycleDependency(): LifecycleDependency? = + ScreenModelStore.getDependencyOrNull(this, LifecycleDependency.KEY) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenModelStore.kt b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenModelStore.kt index dde48d1..264ebf8 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenModelStore.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenModelStore.kt @@ -9,12 +9,15 @@ import org.jetbrains.annotations.VisibleForTesting import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong -internal typealias DependencyKey = String +typealias DependencyKey = String private typealias ScreenModelKey = String -private typealias DependencyInstance = Any private typealias DependencyOnDispose = (Any) -> Unit -private typealias Dependency = Pair + +data class Dependency( + val dependencyInstance: Any, + val onDispose: DependencyOnDispose, +) /** * Class that stores remove priority for a dependency with a dependency it-selves. @@ -53,30 +56,15 @@ object ScreenModelStore { private val Screen.isRemoved: Boolean get() = screenKey.isRemoved private val ScreenKey.isRemoved: Boolean get() = removedScreenKeys[this] != null - @PublishedApi - internal inline fun getKey(screen: Screen, tag: String?): ScreenModelKey = - "${screen.screenKey.value}:${T::class.qualifiedName}:${tag ?: "default"}" - - @PublishedApi - internal fun getDependencyKey(screenModel: ScreenModel, name: String): DependencyKey = - screenModels - .firstNotNullOfOrNull { - if (it.value == screenModel) it.key else null - } - ?: lastScreenModelKey.value - ?.let { "$it:$name" } - ?: "standalone:$name" - - @PublishedApi - internal inline fun getOrPut( + inline fun getOrPutDependency( screen: Screen, - tag: String?, - factory: @DisallowComposableCalls () -> T + name: String, + tag: String? = null, + noinline onDispose: @DisallowComposableCalls (T) -> Unit = {}, + factory: @DisallowComposableCalls (DependencyKey) -> T ): T { - val key = getKey(screen, tag) - lastScreenModelKey.value = key - assertGetOrPutScreenModelsCorrect(screen, screenModels[key]) - return screenModels.getOrPut(key, factory) as T + val key = getDependencyKey(screen, name, tag) + return getOrPutDependency(key, factory, onDispose) } inline fun getOrPutDependency( @@ -89,7 +77,8 @@ object ScreenModelStore { return getOrPutDependency(key, factory, onDispose) } - inline fun getOrPutDependency( + @PublishedApi + internal inline fun getOrPutDependency( key: DependencyKey, factory: @DisallowComposableCalls (DependencyKey) -> T, noinline onDispose: @DisallowComposableCalls (T) -> Unit @@ -97,22 +86,30 @@ object ScreenModelStore { assertGetOrPutDependencyCorrect(key, dependencies[key]) return dependencies .getOrPut(key) { - DependencyWithRemoveOrder(dependencyCounter.getAndIncrement(), (factory(key) to onDispose) as Dependency) + DependencyWithRemoveOrder( + dependencyCounter.getAndIncrement(), + Dependency( + dependencyInstance = factory(key), + onDispose = { onDispose(it as T) } + ) + ) } .dependency - .first as T + .dependencyInstance as T } - inline fun getOrPutDependency( + inline fun getDependencyOrNull( screen: Screen, name: String, tag: String? = null, - noinline onDispose: @DisallowComposableCalls (T) -> Unit = {}, - factory: @DisallowComposableCalls (DependencyKey) -> T - ): T { - val key = getDependencyKey(screen, name, tag) - return getOrPutDependency(key, factory, onDispose) - } + ): T? = getDependencyOrNull(getDependencyKey(screen, name, tag)) + + @PublishedApi + internal inline fun getDependencyOrNull( + key: DependencyKey, + ): T? = dependencies[key] + ?.dependency + ?.dependencyInstance as? T fun getDependencyKey(screen: Screen, name: String, tag: String? = null) = "${screen.screenKey.value}:$name${if (tag != null) ":$tag" else ""}" @@ -128,17 +125,54 @@ object ScreenModelStore { screenModels -= key } - val screenDependencies = dependencies - .screenDependencies(screen) - .toList() - screenDependencies.sortedBy { it.value.removePriority }.forEach { (key, value) -> - val (instance, onDispose) = value.dependency + screenDependenciesSortedByRemovePriorityWithKey(screen).forEach { (key, dependency) -> + val (instance, onDispose) = dependency onDispose(instance) dependencies -= key } removedScreenKeys[screen.screenKey] = Unit } + fun screenDependenciesSortedByRemovePriorityWithKey(screen: Screen): Sequence> = + screenDependenciesInternal(screen) + .sortedBy { it.value.removePriority } + .map { (key, dependencyWithRemoveOrder) -> key to dependencyWithRemoveOrder.dependency } + + fun screenDependenciesSortedByRemovePriority(screen: Screen): Sequence = + screenDependenciesInternal(screen).sortedBy { it.value.removePriority }.map { it.value.dependency.dependencyInstance } + + internal fun screenDependenciesInternal(screen: Screen): Sequence> = + dependencies.asSequence().filter { it.key.startsWith(screen.screenKey.value) } + + /** + * Generates a key based on input parameters. + */ + @PublishedApi + internal inline fun getKey(screen: Screen, tag: String?): ScreenModelKey = + "${screen.screenKey.value}:${T::class.qualifiedName}:${tag ?: "default"}" + + @PublishedApi + internal fun getDependencyKey(screenModel: ScreenModel, name: String): DependencyKey = + screenModels + .firstNotNullOfOrNull { + if (it.value == screenModel) it.key else null + } + ?: lastScreenModelKey.value + ?.let { "$it:$name" } + ?: "standalone:$name" + + @PublishedApi + internal inline fun getOrPut( + screen: Screen, + tag: String?, + factory: @DisallowComposableCalls () -> T + ): T { + val key = getKey(screen, tag) + lastScreenModelKey.value = key + assertGetOrPutScreenModelsCorrect(screen, screenModels[key]) + return screenModels.getOrPut(key, factory) as T + } + @PublishedApi internal fun assertGetOrPutScreenModelsCorrect(screen: Screen, valueInMap: Any?) { assertGetOrPutCorrect( @@ -181,9 +215,6 @@ object ScreenModelStore { } } - private fun Map.screenDependencies(screen: Screen): Sequence> = - asSequence().filter { it.key.startsWith(screen.screenKey.value) } - private fun Map.onEach(screen: Screen, block: (String) -> Unit) = asSequence() .filter { it.key.startsWith(screen.screenKey.value) } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt index 4867e61..52f6bd2 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt @@ -7,17 +7,42 @@ import com.github.terrakok.modo.ReducerAction interface MultiScreenAction : NavigationAction fun interface MultiScreenReducerAction : MultiScreenAction, ReducerAction -class SetContainers(val state: MultiScreenState) : MultiScreenReducerAction { +@Deprecated( + message = "Class with this name was renamed to SelectScreen. This typealias will be removed in further releases.", + replaceWith = ReplaceWith("SetMultiScreenState") +) +typealias SetContainers = SetMultiScreenState + +class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducerAction { override fun reduce(oldState: MultiScreenState): MultiScreenState = state } -class SelectContainer(private val index: Int) : MultiScreenReducerAction { +@Deprecated( + message = "Class with this name was renamed to SelectScreen. This typealias will be removed in further releases.", + replaceWith = ReplaceWith("SelectScreen") +) +typealias SelectContainer = SelectScreen + +class SelectScreen(private val pos: Int) : MultiScreenReducerAction { override fun reduce(oldState: MultiScreenState): MultiScreenState = - oldState.copy(selected = index) + oldState.copy(selected = pos) } fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenState) = dispatch(MultiScreenReducerAction(action)) -fun NavigationContainer.setContainers(state: MultiScreenState) = dispatch(SetContainers(state)) -fun NavigationContainer.selectContainer(index: Int) = dispatch(SelectContainer(index)) \ No newline at end of file +@Deprecated( + message = "This function was renamed to setState. This function will be removed in further releases.", + replaceWith = ReplaceWith("setState(state)") +) +fun NavigationContainer.setContainers(state: MultiScreenState) = setState(state) + +@Deprecated( + message = "This function was renamed to selectScreen. This function will be removed in further releases.", + replaceWith = ReplaceWith("selectScreen(index)") +) +fun NavigationContainer.selectContainer(index: Int) = selectScreen(index) + +fun NavigationContainer.setState(state: MultiScreenState) = dispatch(SetMultiScreenState(state)) + +fun NavigationContainer.selectScreen(pos: Int) = dispatch(SelectScreen(pos)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt index ac5919a..80c27d2 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt @@ -12,9 +12,14 @@ typealias MultiScreenNavModel = NavModel interface MultiScreenNavContainer : NavigationContainer fun MultiScreenNavModel( - containers: List, - selected: Int -) = MultiScreenNavModel(MultiScreenState(containers, selected)) + screens: List, + selected: Int = 0 +) = MultiScreenNavModel(MultiScreenState(screens, selected)) + +fun MultiScreenNavModel( + vararg screens: Screen, + selected: Int = 0 +) = MultiScreenNavModel(MultiScreenState(screens.toList(), selected)) @Parcelize data class MultiScreenState( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt index 32bc293..19c8c4a 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogWindowProvider import androidx.core.view.WindowCompat -import com.github.terrakok.modo.ComposeRenderer import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi @@ -73,6 +72,7 @@ abstract class StackScreen( dialogModifier: Modifier = Modifier, content: RendererContent = defaultRendererContent ) { + StackBackHandler() val screensToRender: ScreensToRender by rememberScreensToRender() screensToRender.screen?.let { screen -> Content(screen, modifier, content) @@ -132,6 +132,18 @@ abstract class StackScreen( } } + @Composable + private fun StackBackHandler() { + val isBackHandlerEnabled by remember { + derivedStateOf { + defaultBackHandler && navigationState.getChildScreens().size > 1 + } + } + BackHandler(enabled = isBackHandlerEnabled) { + back() + } + } + @Composable @OptIn(ExperimentalModoApi::class) private fun StackScreen.RenderDialog( @@ -148,11 +160,6 @@ abstract class StackScreen( onDismissRequest = { back() }, properties = dialogConfig.dialogProperties ) { - DisposableEffect(Unit) { - onDispose { - (renderer as ComposeRenderer).transitionCompleteChannel.trySend(Unit) - } - } val parent = LocalView.current.parent DisposableEffect(parent) { (parent as? DialogWindowProvider)?.window?.let { window -> @@ -180,14 +187,6 @@ abstract class StackScreen( modifier: Modifier = Modifier, content: RendererContent = defaultRendererContent ) { - val isBackHandlerEnabled by remember { - derivedStateOf { - defaultBackHandler && navigationState.getChildScreens().size > 1 - } - } - BackHandler(enabled = isBackHandlerEnabled) { - back() - } super.InternalContent(screen, modifier, content) } @@ -227,7 +226,7 @@ data class DialogPlaceHolder( override fun Content(modifier: Modifier) { Box( // ignore modifier, because it is just invisible placeholder - Modifier.fillMaxSize() + modifier ) } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c0141dc..a71087a 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -34,11 +34,13 @@ dependencies { implementation(libs.androidx.lifecycle.runtimeKtx) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment) implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3) implementation(libs.debug.logcat) + implementation(libs.kotlinx.coroutines.android) debugImplementation(libs.leakcanary.android) } \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 43d2a92..a5c07b0 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + android:theme="@style/Theme.Modo" + tools:ignore="MissingApplicationIcon"> diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt b/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt index c4df1fe..d73c1b7 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt @@ -1,6 +1,6 @@ package com.github.terrakok.modo.sample -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -18,7 +18,7 @@ import com.github.terrakok.modo.animation.calculateStackTransitionType import com.github.terrakok.modo.stack.StackState @Composable -@OptIn(ExperimentalAnimationApi::class, ExperimentalModoApi::class) +@OptIn(ExperimentalModoApi::class) fun ComposeRendererScope.SlideTransition( modifier: Modifier = Modifier, screenModifier: Modifier = Modifier @@ -43,8 +43,8 @@ fun ComposeRendererScope.SlideTransition( StackTransitionType.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size }) else -> ({ size: Int -> size }) to ({ size: Int -> -size }) } - slideInHorizontally(initialOffsetX = initialOffset) togetherWith - slideOutHorizontally(targetOffsetX = targetOffset) + slideInHorizontally(initialOffsetX = initialOffset, animationSpec = tween(durationMillis = 1000)) togetherWith + slideOutHorizontally(targetOffsetX = targetOffset, animationSpec = tween(durationMillis = 1000)) } } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt index a69f727..5a91a4a 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import com.github.terrakok.modo.ModoDevOptions import logcat.AndroidLogcatLogger import logcat.LogPriority +import logcat.logcat class ModoSampleApplication : Application() { @@ -13,5 +14,8 @@ class ModoSampleApplication : Application() { ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { throwable -> throw throwable } + ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable -> + logcat(priority = LogPriority.ERROR) { "Cleaning state of composable, which still can be visible for user." } + } } } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index d001563..3ed88a5 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -7,15 +7,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect -import com.github.terrakok.modo.lifecycle.OnScreenRemoved import com.github.terrakok.modo.sample.ModoLegacyIntegrationActivity import com.github.terrakok.modo.sample.ModoSampleActivity import com.github.terrakok.modo.sample.fragment.ModoFragment @@ -32,6 +26,8 @@ import com.github.terrakok.modo.sample.screens.containers.custom.RemovableItemCo import com.github.terrakok.modo.sample.screens.containers.custom.SampleCustomContainerScreen import com.github.terrakok.modo.sample.screens.containers.list.SampleListNavigation import com.github.terrakok.modo.sample.screens.dialogs.DialogsPlayground +import com.github.terrakok.modo.sample.screens.lifecycle.KeyboardWithLifecycleScreen +import com.github.terrakok.modo.sample.screens.lifecycle.LifecycleSampleScreen import com.github.terrakok.modo.sample.screens.stack.StackActionsScreen import com.github.terrakok.modo.sample.screens.viewmodel.AndroidViewModelSampleScreen import com.github.terrakok.modo.stack.LocalStackNavigation @@ -40,7 +36,6 @@ import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward import com.github.terrakok.modo.util.getActivity import kotlinx.parcelize.Parcelize -import logcat.logcat @Parcelize class MainScreen( @@ -49,17 +44,16 @@ class MainScreen( override val screenKey: ScreenKey = generateScreenKey() ) : Screen { - @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { - OnScreenRemoved { - logcat { "Screen $screenKey was removed" } - } - LifecycleScreenEffect { - LifecycleEventObserver { _: LifecycleOwner, event: Lifecycle.Event -> - logcat { "$screenKey: Lifecycle.Event $event" } - } - } +// OnScreenRemoved { +// logcat { "Screen $screenKey was removed" } +// } +// LifecycleScreenEffect { +// LifecycleEventObserver { _: LifecycleOwner, event: Lifecycle.Event -> +// logcat { "$screenKey: Lifecycle.Event $event" } +// } +// } MainScreenContent( screenIndex = screenIndex, screenKey = screenKey, @@ -71,7 +65,7 @@ class MainScreen( } @Composable -internal fun MainScreenContent( +internal fun Screen.MainScreenContent( screenIndex: Int, screenKey: ScreenKey, navigation: StackNavContainer?, @@ -81,7 +75,6 @@ internal fun MainScreenContent( ButtonsScreenContent( screenIndex = screenIndex, screenName = "MainScreen", - screenKey = screenKey, state = rememberButtons( screenKey = screenKey, navigation = navigation, @@ -93,9 +86,8 @@ internal fun MainScreenContent( } @Composable -internal fun MainScreenContent( +internal fun Screen.MainScreenContent( screenIndex: Int, - screenKey: ScreenKey, counter: Int, navigation: StackNavContainer, modifier: Modifier = Modifier, @@ -151,7 +143,6 @@ private fun rememberButtons( ModoButtonSpec("Stacks in LazyColumn") { navigation?.forward(StackInLazyColumnScreen()) }, ModoButtonSpec("Dialogs & BottomSheets") { navigation?.forward(DialogsPlayground(i + 1)) }, ModoButtonSpec("Multiscreen") { navigation?.forward(SampleMultiScreen()) }, - ModoButtonSpec("Screen Effects") { navigation?.forward(ScreenEffectsSampleScreen(i + 1)) }, ModoButtonSpec("Custom Container Actions") { navigation?.forward(SampleCustomContainerScreen()) }, ModoButtonSpec("Removable screen") { navigation?.forward(RemovableItemContainerScreen(useCustomReducer = false)) }, ModoButtonSpec("Removable screen with reducer") { @@ -168,6 +159,13 @@ private fun rememberButtons( ModoButtonSpec("List/Details") { navigation?.forward(ListScreen()) }, ) ), + GroupedButtonsState.Group( + title = "Lifecycle", + buttons = listOf( + ModoButtonSpec("Screen Lifecycle") { navigation?.forward(LifecycleSampleScreen(i + 1)) }, + ModoButtonSpec("Keyboard + Lifecycle") { navigation?.forward(KeyboardWithLifecycleScreen()) }, + ) + ), GroupedButtonsState.Group( title = "Integrations", listOfNotNull( diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt index db76485..c4c3d4d 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.IntState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,7 +23,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect import com.github.terrakok.modo.sample.SampleAppConfig import com.github.terrakok.modo.sample.randomBackground import com.github.terrakok.modo.sample.screens.ButtonsState @@ -31,17 +37,18 @@ import com.github.terrakok.modo.sample.screens.GroupedButtonsState import com.github.terrakok.modo.sample.screens.ModoButtonSpec import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import logcat.logcat internal const val COUNTER_DELAY_MS = 100L @Composable -internal fun ButtonsScreenContent( +internal fun Screen.ButtonsScreenContent( screenIndex: Int, screenName: String, - screenKey: ScreenKey, state: GroupedButtonsState, modifier: Modifier = Modifier, ) { + LogLifecycle() val counter by rememberCounterState() ButtonsScreenContent(screenIndex, screenName, counter, screenKey, state, modifier) } @@ -84,17 +91,42 @@ internal fun ButtonsScreenContent( } @Composable -internal fun SampleScreenContent( +internal fun Screen.SampleScreenContent( screenIndex: Int, screenName: String, screenKey: ScreenKey, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit ) { + LogLifecycle() val counter by rememberCounterState() SampleScreenContent(screenIndex, screenName, counter, screenKey, modifier, content) } +@OptIn(ExperimentalModoApi::class) +@Composable +fun Screen.LogLifecycle() { + val lifecycleOwner = LocalLifecycleOwner.current + + // You will not be able to observe updates of lifecycleOwner when this content is not in the composition + DisposableEffect(lifecycleOwner) { + logcat(tag = "LifecycleDebug") { "$screenKey DisposableEffect" } + val observer = LifecycleEventObserver { _, event -> + logcat(tag = "LifecycleDebug") { "$screenKey DisposableEffect $event" } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + logcat(tag = "LifecycleDebug") { "$screenKey DisposableEffect onDispose" } + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + LifecycleScreenEffect { + LifecycleEventObserver { source, event -> + logcat(tag = "LifecycleDebug") { "$screenKey LifecycleScreenEffect $event" } + } + } +} + @Composable internal fun SampleScreenContent( screenIndex: Int, diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt index 22d5ac7..4f8479e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt @@ -12,13 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect -import com.github.terrakok.modo.model.OnScreenRemoved +import com.github.terrakok.modo.lifecycle.OnScreenRemoved import com.github.terrakok.modo.sample.SlideTransition import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen @@ -48,11 +44,6 @@ class CustomStackSample( @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { - LifecycleScreenEffect { - LifecycleEventObserver { source: LifecycleOwner, event: Lifecycle.Event -> - logcat { "$screenKey: Lifecycle.Event $event" } - } - } OnScreenRemoved { logcat { "Screen $screenKey was removed" } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt index 107b75b..8ad42b0 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt @@ -39,7 +39,7 @@ import kotlinx.parcelize.Parcelize @Parcelize internal class SampleMultiScreen( private val navModel: MultiScreenNavModel = MultiScreenNavModel( - containers = listOf( + screens = listOf( SampleStack(MainScreen(1)), SampleStack(MainScreen(2)), SampleStack(MainScreen(3)), diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt index ee21dd2..b67c06b 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt @@ -14,12 +14,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.lifecycle.LifecycleEventObserver import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen -import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect import com.github.terrakok.modo.sample.SlideTransition +import com.github.terrakok.modo.sample.screens.base.LogLifecycle import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheet import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheetStack import com.github.terrakok.modo.stack.DialogPlaceHolder @@ -29,7 +28,6 @@ import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import kotlinx.parcelize.Parcelize -import logcat.logcat class OpenActivityAction( private val context: Context, @@ -57,12 +55,11 @@ open class SampleStack( @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { - LifecycleScreenEffect { - LifecycleEventObserver { _, event -> - logcat(tag = "SampleStack") { "$screenKey lifecycle event $event" } - } - } - TopScreenContent(modifier) { modifier -> + LogLifecycle() + TopScreenContent( + modifier, + dialogModifier = modifier.fillMaxSize() + ) { modifier -> SlideTransition(modifier) } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/InnerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/InnerScreen.kt index 680315e..abb57b0 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/InnerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/InnerScreen.kt @@ -13,36 +13,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.LifecycleEventObserver -import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.LocalContainerScreen import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.randomBackground +import com.github.terrakok.modo.sample.screens.base.LogLifecycle import com.github.terrakok.modo.sample.screens.base.rememberCounterState import kotlinx.parcelize.Parcelize -import logcat.logcat @Parcelize internal class InnerScreen( override val screenKey: ScreenKey = generateScreenKey() ) : Screen { - @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { val parent = LocalSampleCustomNavigation.current val closeScreen by rememberUpdatedState { parent.dispatch(RemoveScreen(screenKey)) } - LifecycleScreenEffect { - LifecycleEventObserver { _, event -> - logcat(tag = "InnerScreen Lifecycle") { "$screenKey $event" } - } - } + LogLifecycle() InnerContent( title = "Screen $screenKey", onRemoveClick = closeScreen, diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt index a47ee94..b7aff12 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt @@ -35,6 +35,7 @@ import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import kotlinx.parcelize.Parcelize +import logcat.logcat @Stable @Parcelize @@ -144,14 +145,18 @@ internal class SampleCustomContainerScreen( ): @Composable (item: Screen) -> Unit { val screens = navigationState.screens val composedItems = remember { mutableStateMapOf Unit>() } - DisposableEffect(key1 = this) { + DisposableEffect(key1 = screens) { + logcat { "movableScreen" } val movableContentScreens = composedItems.keys val actualScreens = screens.toSet() val removedScreens = movableContentScreens - actualScreens removedScreens.forEach { composedItems -= it } - onDispose {} + logcat { "movableScreen Removed screens: $removedScreens" } + onDispose { + logcat { "movableScreen onDispose Removed screens: $removedScreens" } + } } return { item: Screen -> composedItems.getOrPut(item) { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/DialogsPlayground.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/DialogsPlayground.kt index 17f1f82..630ff15 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/DialogsPlayground.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/DialogsPlayground.kt @@ -12,6 +12,7 @@ import com.github.terrakok.modo.sample.screens.GroupedButtonsState import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.ModoButtonSpec import com.github.terrakok.modo.sample.screens.base.ButtonsScreenContent +import com.github.terrakok.modo.sample.screens.base.LogLifecycle import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.StackNavContainer import com.github.terrakok.modo.stack.forward @@ -24,16 +25,16 @@ class DialogsPlayground( ) : Screen { @Composable override fun Content(modifier: Modifier) { - DialogsPlaygroundContent(screenIndex, screenKey) + DialogsPlaygroundContent(screenIndex) } } @Composable -internal fun DialogsPlaygroundContent(screenIndex: Int, screenKey: ScreenKey, modifier: Modifier = Modifier) { +internal fun Screen.DialogsPlaygroundContent(screenIndex: Int, modifier: Modifier = Modifier) { + LogLifecycle() ButtonsScreenContent( screenIndex = screenIndex, screenName = "DialogsPlayground", - screenKey = screenKey, state = rememberDialogsButtons(LocalStackNavigation.current, screenIndex), modifier = modifier ) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/M3BottomSheet.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/M3BottomSheet.kt index d11739b..1c89760 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/M3BottomSheet.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/M3BottomSheet.kt @@ -45,7 +45,6 @@ class M3BottomSheet( ButtonsScreenContent( screenIndex = screenIndex, screenName = "SampleDialog", - screenKey = screenKey, state = rememberDialogsButtons(LocalContainerScreen.current as StackScreen, screenIndex), modifier = modifier .fillMaxSize() diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt index 6e87d2d..ad21024 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt @@ -74,7 +74,7 @@ class SampleDialog( .background(Color.White) ) { if (dialogsPlayground) { - DialogsPlaygroundContent(screenIndex, screenKey) + DialogsPlaygroundContent(screenIndex) } else { MainScreenContent(screenIndex, screenKey, navigation) } @@ -84,7 +84,6 @@ class SampleDialog( ButtonsScreenContent( screenIndex = screenIndex, screenName = "SampleDialog", - screenKey = screenKey, state = rememberDialogsButtons(LocalContainerScreen.current as StackScreen, screenIndex), modifier = modifier .align(Alignment.Center) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SystemDialogWithCustomDimSample.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SystemDialogWithCustomDimSample.kt index 5f9be30..620fd59 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SystemDialogWithCustomDimSample.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SystemDialogWithCustomDimSample.kt @@ -52,7 +52,7 @@ class SystemDialogWithCustomDimSample( .clip(RoundedCornerShape(16.dp)) .background(Color.White) ) { - DialogsPlaygroundContent(screenIndex, screenKey) + DialogsPlaygroundContent(screenIndex) } } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/KeyboardWithLifecycleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/KeyboardWithLifecycleScreen.kt new file mode 100644 index 0000000..1185ef6 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/KeyboardWithLifecycleScreen.kt @@ -0,0 +1,76 @@ +package com.github.terrakok.modo.sample.screens.lifecycle + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.sample.screens.ModoButton +import com.github.terrakok.modo.sample.screens.ModoButtonSpec +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.back +import com.github.terrakok.modo.util.getActivity +import kotlinx.parcelize.Parcelize + +/** + * Sample of showing keyboard when animation is finished and hiding, when animation started. + * Using ON_RESUME and ON_PAUSE to achieve this. + */ +@Parcelize +class KeyboardWithLifecycleScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + @Composable + override fun Content(modifier: Modifier) { + Column(modifier.windowInsetsPadding(WindowInsets.systemBars)) { + val stackNavigation = LocalStackNavigation.current + ModoButton(ModoButtonSpec("Back") { stackNavigation.back() }) + + Text("Show ON_RESUME, hide ON_PAUSE") + + val (text, setText) = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val lifecycleOwner = LocalLifecycleOwner.current + val keyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + DisposableEffect(this) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + focusRequester.requestFocus() + } + Lifecycle.Event.ON_PAUSE -> { + if (context.getActivity()?.isChangingConfigurations != true) { + focusRequester.freeFocus() + keyboardController?.hide() + } + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + TextField(text, setText, modifier = Modifier.focusRequester(focusRequester)) + } + } +} \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/ScreenEffectsSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt similarity index 54% rename from sample/src/main/java/com/github/terrakok/modo/sample/screens/ScreenEffectsSampleScreen.kt rename to sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt index a69bda4..201b116 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/ScreenEffectsSampleScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt @@ -1,10 +1,12 @@ -package com.github.terrakok.modo.sample.screens +package com.github.terrakok.modo.sample.screens.lifecycle import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,28 +15,40 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.eventFlow import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.lifecycle.DisposableScreenEffect import com.github.terrakok.modo.lifecycle.LaunchedScreenEffect import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect +import com.github.terrakok.modo.sample.screens.ButtonsState +import com.github.terrakok.modo.sample.screens.GroupedButtonsList +import com.github.terrakok.modo.sample.screens.MainScreen +import com.github.terrakok.modo.sample.screens.ModoButtonSpec import com.github.terrakok.modo.sample.screens.base.SampleScreenContent import com.github.terrakok.modo.sample.screens.base.rememberCounterState import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import logcat.logcat @Parcelize -class ScreenEffectsSampleScreen( +class LifecycleSampleScreen( private val screenIndex: Int, override val screenKey: ScreenKey = generateScreenKey() ) : Screen { - @OptIn(ExperimentalModoApi::class) + companion object { + private const val TAG = "ScreenEffectsSampleScreen" + } + + @OptIn(ExperimentalModoApi::class, ExperimentalStdlibApi::class) @Composable override fun Content(modifier: Modifier) { var lifecycleEventsHistory by rememberSaveable { @@ -42,20 +56,41 @@ class ScreenEffectsSampleScreen( } val scaffoldState = rememberScaffoldState() val counter by rememberCounterState() - // This effect is going to be launched once despite on activity recreation. + + // This effect is going to be launched once per screen an triggered even if screen left composition! + // So be careful with passing any data to lambda which lifecycle is shorter than screen lifecycle. + // F.e. capturing context cause leak of the context. LaunchedScreenEffect { + // Doing so will cause a leak of the scaffoldState. scaffoldState.snackbarHostState.showSnackbar("LaunchedScreenEffect! Counter: $counter.") } - LifecycleScreenEffect { - LifecycleEventObserver { _, event -> - lifecycleEventsHistory += event - logcat { "Lifecycle event $event. Counter: $counter." } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + // Use Dispatchers.Main.immediate, otherwise you will lose ON_PAUSE, ON_STOP, ON_DESTROY events, + // because of peculiarities of coroutines - it removes lifecycle observer before handling effects + // You can try out and remove Dispatchers.Main.immediate to see the difference. + withContext(Dispatchers.Main.immediate) { + val dispatcher = coroutineContext[CoroutineDispatcher.Key] + logcat(TAG) { "LaunchedEffect $dispatcher" } + lifecycleOwner.lifecycle.eventFlow.collect { event -> + logcat(TAG) { "LaunchedEffect: event $event. Counter: $counter." } + } } } - DisposableScreenEffect { - logcat { "Analytics: screen created. Counter: $counter." } + DisposableEffect(this) { + val observer = LifecycleEventObserver { _, event -> + logcat(TAG) { "DisposableEffect: event $event. Counter: $counter." } + } + lifecycleOwner.lifecycle.addObserver(observer) onDispose { - logcat { "Analytics: screen destroyed. Counter: $counter." } + logcat(TAG) { "DisposableEffect: on dispose" } + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + LifecycleScreenEffect { + LifecycleEventObserver { _, event -> + lifecycleEventsHistory += event + logcat(TAG) { "LifecycleScreenEffect: event $event. Counter: $counter." } } } val navigation = LocalStackNavigation.current diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt index e37322f..567f06f 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt @@ -49,7 +49,6 @@ internal class StackActionsScreen( ButtonsScreenContent( modifier = modifier, screenName = "StackActionsScreen", - screenKey = screenKey, screenIndex = screenIndex, state = rememberButtons( LocalStackNavigation.current, @@ -106,6 +105,9 @@ private fun rememberButtons( ModoButtonSpec("Back to '3'") { navigation.backTo { pos, _ -> pos == 2 } }, + ModoButtonSpec("Back 3 screens") { + navigation.back(3) + }, ModoButtonSpec("Back 2 screens + Forward") { navigation.dispatch( Back(2), diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt index b6b4dea..a447c22 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt @@ -3,19 +3,14 @@ package com.github.terrakok.modo.sample.screens.viewmodel import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect import com.github.terrakok.modo.sample.screens.MainScreenContent import com.github.terrakok.modo.sample.screens.base.COUNTER_DELAY_MS import com.github.terrakok.modo.stack.LocalStackNavigation @@ -32,56 +27,12 @@ internal class AndroidViewModelSampleScreen( override val screenKey: ScreenKey = generateScreenKey() ) : Screen { - @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { -// val lifecycleOwner = LocalLifecycleOwner.current - - // You will lose onResume, onStop, if you use regular DisposableEffect or LaunchedEffect, because it finishes as soon it leaves composition. -// DisposableEffect(lifecycleOwner) { -// val observer = LifecycleEventObserver { _, event -> -// logcat { "AndroidViewModelSampleScreen DisposableEffect $screenKey: event $event" } -// } -// lifecycleOwner.lifecycle.addObserver(observer) -// onDispose { -// lifecycleOwner.lifecycle.removeObserver(observer) -// } -// } - - // Coroutines way -// LaunchedScreenEffect { -// lifecycleOwner.lifecycle.eventFlow.collect { lifecycleState -> -// logcat { "AndroidViewModelSampleScreen $screenKey: LifecycleState $lifecycleState" } -// } -// } - - // Disposable observer way -// DisposableScreenEffect { -// logcat { "AndroidViewModelSampleScreen $screenPos DisposableScreenEffect created" } -// val observer = object : LifecycleEventObserver { -// override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { -// logcat { "AndroidViewModelSampleScreen $screenKey observer: Lifecycle.Event $event" } -// } -// } -// lifecycleOwner.lifecycle.addObserver(observer) -// onDispose { -// logcat { "AndroidViewModelSampleScreen $screenKey DisposableScreenEffect disposed" } -// lifecycleOwner.lifecycle.removeObserver(observer) -// } -// } - - LifecycleScreenEffect { - object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - logcat { "AndroidViewModelSampleScreen $screenKey: Lifecycle.Event $event" } - } - } - } - val viewModel: SampleViewModel = viewModel { SampleViewModel(screenPos, createSavedStateHandle()) } - MainScreenContent(screenPos, screenKey, viewModel.stateFlow.collectAsState().value, LocalStackNavigation.current, modifier) + MainScreenContent(screenPos, viewModel.stateFlow.collectAsState().value, LocalStackNavigation.current, modifier) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index f174499..99ca038 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven("https://jitpack.io") } } @@ -20,3 +21,4 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":modo-compose") include(":sample") +include(":workshop-app") \ No newline at end of file diff --git a/workshop-app/build.gradle.kts b/workshop-app/build.gradle.kts new file mode 100644 index 0000000..1e7c3af --- /dev/null +++ b/workshop-app/build.gradle.kts @@ -0,0 +1,49 @@ +import com.github.terrakok.configureJetpackCompose +import com.github.terrakok.configureKotlinAndroid + +plugins { + alias(libs.plugins.modo.android.app) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "io.github.ikarenkov.workshop" + + configureKotlinAndroid(this) + configureJetpackCompose(this) + + defaultConfig { + applicationId = "io.github.ikarenkov.workshopapp" + targetSdk = libs.versions.compileSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom.app)) + androidTestImplementation(platform(libs.androidx.compose.bom.app)) + + // TODO: Workshop 1.1 - Add dependencies for modo + implementation(projects.modoCompose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.foundation.beta) + implementation(libs.androidx.lifecycle.runtimeKtx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment) + implementation(libs.compose.wheelPicker) + implementation(libs.koin.android) + implementation(libs.koin.compose) + + implementation(libs.androidx.compose.material3) + + implementation(libs.debug.logcat) + implementation(libs.kotlinx.coroutines.android) + + debugImplementation(libs.leakcanary.android) +} \ No newline at end of file diff --git a/workshop-app/src/main/AndroidManifest.xml b/workshop-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..142f366 --- /dev/null +++ b/workshop-app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkShopStackScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkShopStackScreen.kt new file mode 100644 index 0000000..59d61cb --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkShopStackScreen.kt @@ -0,0 +1,10 @@ +package io.github.ikarenkov.workshop + +import com.github.terrakok.modo.stack.StackNavModel +import com.github.terrakok.modo.stack.StackScreen +import kotlinx.parcelize.Parcelize + +@Parcelize +class WorkShopStackScreen( + private val navModel: StackNavModel +) : StackScreen(navModel) \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt new file mode 100644 index 0000000..8b21c52 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt @@ -0,0 +1,23 @@ +package io.github.ikarenkov.workshop + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.Surface +import androidx.compose.ui.graphics.Color +import io.github.ikarenkov.workshop.screens.WorkshopTheme + +class WorkshopActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // TODO: Workshop 1.3.1 - remember hierarchy of screens, using build-in rememberRootScreen, DefaultStackScreen and Screen + // TODO: Workshop 3.1.5 - show MainTabScreen first + WorkshopTheme { + Surface(color = Color.White) { + // TODO: Workshop 1.3.2 - displaying content of root screen + } + } + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivityFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivityFinal.kt new file mode 100644 index 0000000..baaef18 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivityFinal.kt @@ -0,0 +1,38 @@ +package io.github.ikarenkov.workshop + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.github.terrakok.modo.Modo.rememberRootScreen +import com.github.terrakok.modo.RootScreen +import com.github.terrakok.modo.stack.DefaultStackScreen +import com.github.terrakok.modo.stack.StackNavModel +import io.github.ikarenkov.workshop.screens.WorkshopTheme +import io.github.ikarenkov.workshop.screens.main.MainTabScreenFinal + +class WorkshopActivityFinal : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // Workshop 1.3.2 - remember hierarchy of screens, using build-in rememberRootrememberRootScreen, DefaultStackScreen and Screen + val rootScreen: RootScreen = rememberRootScreen { + DefaultStackScreen( + StackNavModel( + MainTabScreenFinal() + ) + ) + } + WorkshopTheme { + Surface(color = Color.White) { + // Workshop 1.3.2 - displaying content of root screen + rootScreen.Content(modifier = Modifier.fillMaxSize()) + } + } + } + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt new file mode 100644 index 0000000..81c8eae --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt @@ -0,0 +1,31 @@ +package io.github.ikarenkov.workshop + +import android.app.Application +import com.github.terrakok.modo.ModoDevOptions +import io.github.ikarenkov.workshop.di.rootModule +import logcat.AndroidLogcatLogger +import logcat.LogPriority +import logcat.logcat +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.GlobalContext.startKoin + +class WorkshopApp : Application() { + + override fun onCreate() { + super.onCreate() + AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE) + ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { throwable -> + throw throwable + } + ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable -> + logcat(priority = LogPriority.ERROR) { "Cleaning state of composable, which still can be visible for user." } + } + + startKoin { + androidLogger() + androidContext(this@WorkshopApp) + modules(rootModule) + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopConfig.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopConfig.kt new file mode 100644 index 0000000..54edcf8 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopConfig.kt @@ -0,0 +1,24 @@ +package io.github.ikarenkov.workshop + +import io.github.ikarenkov.workshop.domain.ClimberProfile +import java.util.GregorianCalendar + +object WorkshopConfig { + const val skipLogin = true + val initialClimberProfile = ClimberProfile(dateOfBirth = GregorianCalendar(1990, 1, 1).time) +// ClimberProfile( +// dateOfBirth = GregorianCalendar(1990, 1, 1).time, +// heightSm = 180, +// weightKg = 70f, +// sportLevel = ClimbingLevel( +// redpointGrade = FrenchScaleGrade(7, FrenchScaleGrade.Letter.A), +// onsightGrade = FrenchScaleGrade(6, FrenchScaleGrade.Letter.A), +// flashGrade = FrenchScaleGrade(6, FrenchScaleGrade.Letter.A) +// ), +// boulderLevel = ClimbingLevel( +// redpointGrade = FrenchScaleGrade(7, FrenchScaleGrade.Letter.A), +// onsightGrade = FrenchScaleGrade(6, FrenchScaleGrade.Letter.A), +// flashGrade = FrenchScaleGrade(6, FrenchScaleGrade.Letter.A) +// ), +// ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/core/StateFlowEtx.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/core/StateFlowEtx.kt new file mode 100644 index 0000000..3eaf967 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/core/StateFlowEtx.kt @@ -0,0 +1,27 @@ +package io.github.ikarenkov.workshop.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +fun StateFlow.mapStateFlow( + coroutineScope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + transform: (T) -> R +): StateFlow = map(transform).stateIn(coroutineScope, started, transform(value)) + +fun combineStateFlow( + flow: StateFlow, + flow2: StateFlow, + coroutineScope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + transform: (T1, T2) -> R +): StateFlow = combine(flow, flow2, transform) + .stateIn( + scope = coroutineScope, + started = started, + initialValue = transform(flow.value, flow2.value) + ) \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/data/ClimberProfileRepository.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/data/ClimberProfileRepository.kt new file mode 100644 index 0000000..856dd7c --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/data/ClimberProfileRepository.kt @@ -0,0 +1,17 @@ +package io.github.ikarenkov.workshop.data + +import io.github.ikarenkov.workshop.WorkshopConfig +import io.github.ikarenkov.workshop.domain.ClimberProfile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class ClimberProfileRepository { + val dataSource = MutableStateFlow(WorkshopConfig.initialClimberProfile) + val climberProfile: StateFlow + get() = dataSource + + fun updateClimberProfile(update: ClimberProfile.() -> ClimberProfile) { + dataSource.update(update) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt new file mode 100644 index 0000000..93c1ec0 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt @@ -0,0 +1,23 @@ +package io.github.ikarenkov.workshop.di + +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.screens.climbing_level.ClimbingLevelViewModel +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoViewModel +import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileViewModel +import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileViewModelFinal +import io.github.ikarenkov.workshop.screens.profile_setup.ProfileSetupFlowViewModelFinal +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val rootModule = module { + single { ClimberProfileRepository() } + viewModel { ClimbingLevelViewModel(it.get(), get()) } + viewModel { EnhancedProfileViewModel(get()) } + viewModel { EnhancedProfileViewModelFinal(it.get(), get()) } + viewModel { ClimberPersonalInfoViewModel(get()) } + // TODO: Workshop 5.1.1 - di define ProfileSetupViewModel +// viewModel { ProfileSetupFlowViewModel(get()) } + // TODO: Workshop 5.1.4 - di pass arguments to ProfileSetupViewModel +// viewModel { ProfileSetupFlowViewModel(it.get(), it.get(), get()) } + viewModel { ProfileSetupFlowViewModelFinal(it.get(), it.get(), it.get(), get()) } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimberProfile.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimberProfile.kt new file mode 100644 index 0000000..64b36da --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimberProfile.kt @@ -0,0 +1,22 @@ +package io.github.ikarenkov.workshop.domain + +import java.util.Date + +data class ClimberProfile( + val name: String? = "Igor Karenkov", + val description: String? = "Software engineer, climber, and digital nomad.", + val dateOfBirth: Date? = null, + val heightSm: Int? = null, + val weightKg: Float? = null, + val sportLevel: ClimbingLevel = ClimbingLevel(), + val boulderLevel: ClimbingLevel = ClimbingLevel(), +) + +data class ClimbingLevel( + val redpointGrade: FrenchScaleGrade? = null, + val onsightGrade: FrenchScaleGrade? = null, + val flashGrade: FrenchScaleGrade? = null, +) { + fun hasAnyGrade(): Boolean = redpointGrade != null || onsightGrade != null || flashGrade != null + fun hasAllGrades(): Boolean = redpointGrade != null && onsightGrade != null && flashGrade != null +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimbingType.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimbingType.kt new file mode 100644 index 0000000..e6a29a0 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimbingType.kt @@ -0,0 +1,5 @@ +package io.github.ikarenkov.workshop.domain + +enum class ClimbingType { + Bouldering, Sport +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/FrenchScaleGrade.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/FrenchScaleGrade.kt new file mode 100644 index 0000000..6b0125b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/FrenchScaleGrade.kt @@ -0,0 +1,49 @@ +package io.github.ikarenkov.workshop.domain + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FrenchScaleGrade( + val digit: Int, + val letter: Letter, + val isPlus: Boolean = false +) : Parcelable { + override fun toString(): String = "$digit${letter.name}${if (isPlus) "+" else ""}" + + enum class Letter { + A, B, C + } +} + +val sportRouteGrades = arrayOf( + FrenchScaleGrade(5, FrenchScaleGrade.Letter.A, false), + FrenchScaleGrade(5, FrenchScaleGrade.Letter.A, true), + FrenchScaleGrade(5, FrenchScaleGrade.Letter.B, false), + FrenchScaleGrade(5, FrenchScaleGrade.Letter.B, true), + FrenchScaleGrade(5, FrenchScaleGrade.Letter.C, false), + FrenchScaleGrade(5, FrenchScaleGrade.Letter.C, true), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.A, false), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.A, true), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.B, false), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.B, true), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.C, false), + FrenchScaleGrade(6, FrenchScaleGrade.Letter.C, true), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.A, false), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.A, true), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.B, false), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.B, true), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.C, false), + FrenchScaleGrade(7, FrenchScaleGrade.Letter.C, true), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.A, false), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.A, true), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.B, false), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.B, true), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.C, false), + FrenchScaleGrade(8, FrenchScaleGrade.Letter.C, true), + FrenchScaleGrade(9, FrenchScaleGrade.Letter.A, false), + FrenchScaleGrade(9, FrenchScaleGrade.Letter.A, true), + FrenchScaleGrade(9, FrenchScaleGrade.Letter.B, false), + FrenchScaleGrade(9, FrenchScaleGrade.Letter.B, true), + FrenchScaleGrade(9, FrenchScaleGrade.Letter.C, false), +) \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt new file mode 100644 index 0000000..187e13b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt @@ -0,0 +1,52 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +// TODO: Workshop 1.2.1 - create SampleScreen class implementing Screen interface +// TODO: Workshop 1.2.2 - implement screenKey in the constructor using generateScreenKey() function +// TODO: Workshop 1.2.3 - use @Parcelize annotation to make SampleScreen class Parcelable +// TODO: Workshop 1.2.4 - implement Content function with SampleScreenContent composable +// TODO: Workshop 2.1 - get stack navigation using LocalStackNavigation.current +// TODO: Workshop 2.2 - navigate to next screen using forward function +// TODO: Workshop 2.3.1 - add argument to constructor +// TODO: Workshop 2.3.2 - use it in Content function +// TODO: Workshop 2.3.3 - pass argument to next screen +// TODO: Workshop 3.1.4 - navigate to MainTabScreen from SampleScreen + +@Composable +internal fun SampleScreenContent( + screenIndex: Int, + openNextScreen: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = CenterHorizontally + ) { + Text(text = "Hello, Modo! Screen №$screenIndex") + Button( + onClick = openNextScreen + ) { + Text(text = "Next screen") + } + } +} + +@Preview +@Composable +private fun SampleScreenPreview() { + SampleScreenContent( + modifier = Modifier.fillMaxSize(), + screenIndex = 1, + openNextScreen = {}, + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenFinal.kt new file mode 100644 index 0000000..c0bc564 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenFinal.kt @@ -0,0 +1,35 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.forward +import kotlinx.parcelize.Parcelize + +// Workshop 1.2.1 - create SampleScreen class implementing Screen interface +// Workshop 1.2.3 - use @Parcelize annotation to make SampleScreen class Parcelable +@Parcelize +class SampleScreenFinal( + // You can pass argiment as a constructor parameter + private val screenIndex: Int, + // Workshop 1.2.2 - implement screenKey in the constructor using generateScreenKey() function + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + // Taking a nearest stack navigation container + val stackNavigation = LocalStackNavigation.current + // Workshop 1.2.4 - implement Content function with SampleScreenContent composable + SampleScreenContent( + modifier = modifier, + screenIndex = screenIndex, + openNextScreen = { + stackNavigation.forward(SampleScreenFinal(screenIndex + 1)) + }, + ) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreen.kt new file mode 100644 index 0000000..339d779 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreen.kt @@ -0,0 +1,19 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import kotlinx.parcelize.Parcelize + +@Parcelize +class TrainingRecommendationsDialogScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + TrainingRecommendationsContent(modifier) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreenFinal.kt new file mode 100644 index 0000000..19085ae --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreenFinal.kt @@ -0,0 +1,28 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.DialogScreen +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import kotlinx.parcelize.Parcelize + +@OptIn(ExperimentalModoApi::class) +@Parcelize +@Suppress("MagicNumber") +class TrainingRecommendationsDialogScreenFinal( + override val screenKey: ScreenKey = generateScreenKey() +) : DialogScreen { + + @Composable + override fun Content(modifier: Modifier) { + Surface(modifier, shape = RoundedCornerShape(16.dp)) { + TrainingRecommendationsContent(Modifier.fillMaxHeight(0.8f)) + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsScreen.kt new file mode 100644 index 0000000..946b3e9 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsScreen.kt @@ -0,0 +1,107 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import io.github.ikarenkov.workshop.screens.profile_setup.SetupStepScreen +import kotlinx.parcelize.Parcelize + +@Parcelize +class TrainingRecommendationsScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : SetupStepScreen { + + override val title: String + get() = "Training Recommendations 🎁" + + @Composable + override fun Content(modifier: Modifier) { + TrainingRecommendationsContent(modifier) + } +} + +val recommendations + @Composable + get() = buildAnnotatedString { + withStyle(MaterialTheme.typography.bodyLarge.toSpanStyle()) { + append("We created a ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("training plan") + } + append("for you based on your climbing level and goals. Check it out!\n\n") + } + + withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { + append("🤝\uD83E\uDD1D\uD83E\uDD1D Beginner Boulder Buddy\n") + } + withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = 20.sp))) { + append(" \u2022 Task: Hug every hold like it's your long\u2022lost friend.\n") + append(" \u2022 Goal: Avoid looking down—gravity is just a theory anyway!\n\n") + } + + withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { + append("🐱🐱🐱 Intermediate Wall Whisperer\n") + } + withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = 20.sp))) { + append(" \u2022 Task: Try to convince the wall to cooperate. If it refuses, bribe it with chalk.\n") + append(" \u2022 Goal: Make at least one dramatic fall, so everyone knows you're pushing your limits.\n\n") + } + + withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { + append("🧙🧙🧙 Advanced Route Wizard\n") + } + withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = 20.sp))) { + append(" \u2022 Task: Climb so smoothly that the holds start thinking you're Spider\u2022Man.\n") + append(" \u2022 Goal: Finish every route with a victory dance—bonus points if it's on the top hold.\n\n") + } + + withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { + append("💪💪💪 Pro Crimp Crusher\n") + } + withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = 20.sp))) { + append(" \u2022 Task: Pretend like crimps are just tiny jugs, and you're definitely not feeling the burn.\n") + append(" \u2022 Goal: Laugh maniacally at gravity's futile attempts to bring you down.\n\n") + } + + withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { + append("😈😈😈 Legendary Dyno Daredevil\n") + } + withStyle(style = ParagraphStyle(textIndent = TextIndent(restLine = 20.sp))) { + append(" \u2022 Task: Leap to the next hold as if you're auditioning for an action movie.\n") + append(" \u2022 Goal: Stick the landing with a grin that says, \"I was born for this.\"\n") + } + } + +@Composable +fun TrainingRecommendationsContent( + modifier: Modifier = Modifier +) { + Text( + text = recommendations, + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) +} + +@Preview +@Composable +private fun PreviewTrainingRecommendations() { + TrainingRecommendationsContent(Modifier.fillMaxSize()) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/WorkshopTheme.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/WorkshopTheme.kt new file mode 100644 index 0000000..36f3e59 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/WorkshopTheme.kt @@ -0,0 +1,18 @@ +package io.github.ikarenkov.workshop.screens + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun WorkshopTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + background = Color.White, + surface = Color.White, + ), + content = content + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthCodeScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthCodeScreen.kt new file mode 100644 index 0000000..980594b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthCodeScreen.kt @@ -0,0 +1,190 @@ +package io.github.ikarenkov.workshop.screens.auth + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.border +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.setStack +import io.github.ikarenkov.workshop.screens.main.MainTabScreenFinal +import kotlinx.parcelize.Parcelize + +// TODO: Workshop 7.2 - creating next screen +@Parcelize +class AuthCodeScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + val navigation = LocalStackNavigation.current + val keyboardController = LocalSoftwareKeyboardController.current + AuthCodeScreenContent( + modifier = modifier.fillMaxWidth(), + onCodeEntered = { code -> + keyboardController?.hide() + navigation.setStack(MainTabScreenFinal()) + } + ) + } +} + +@Composable +internal fun AuthCodeScreenContent( + onCodeEntered: (code: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // TODO: Workshop 7.2 - keyboard focus when animation finished + val focusRequester = remember { FocusRequester() } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + focusRequester.requestFocus() + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Text("Enter the code", style = MaterialTheme.typography.headlineLarge) + + Spacer(Modifier.height(20.dp)) + + val (text, setText) = rememberSaveable { mutableStateOf("") } + CodeInputTextField( + codeLength = 4, + value = text, + onValueChange = { + setText(it) + if (it.length == 4) { + onCodeEntered(it) + } + }, + modifier = Modifier + .focusRequester(focusRequester) + ) + } +} + +@Preview +@Composable +private fun PreviewAuthCodeScreen() { + AuthCodeScreenContent(modifier = Modifier.fillMaxSize(), onCodeEntered = {}) +} + +@Composable +fun CodeInputTextField( + codeLength: Int, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + BasicTextField( + value = value, + onValueChange = { newValue -> + if (newValue.length > codeLength) { + onValueChange(newValue.take(codeLength)) + } else { + onValueChange(newValue) + } + }, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + showKeyboardOnFocus = true + ), + modifier = modifier, + ) { innerTextField -> + Row { + repeat(codeLength) { index -> + val char = if (index < value.length) value[index] else null + CharBox( + char, + focused = index == value.length + ) + if (index != codeLength - 1) { + Spacer(Modifier.width(8.dp)) + } + } + } + + } +} + +@Preview +@Composable +private fun PreviewCodeInputTextField() { + CodeInputTextField(4, "1234", {}) +} + +@Composable +private fun CharBox( + char: Char?, + focused: Boolean = false +) { + val borderColor by animateColorAsState(if (focused) Color.Black else Color.LightGray) + Text( + text = char?.toString().orEmpty(), + modifier = Modifier + .width(40.dp) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) +} + +@Preview +@Composable +private fun PreviewCharBox() { + CharBox('1') +} + +@Preview +@Composable +private fun PreviewEmptyCharBox() { + CharBox(' ') +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthContainerScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthContainerScreen.kt new file mode 100644 index 0000000..03e1b78 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthContainerScreen.kt @@ -0,0 +1,21 @@ +package io.github.ikarenkov.workshop.screens.auth + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.animation.ScreenTransition +import com.github.terrakok.modo.stack.StackNavModel +import com.github.terrakok.modo.stack.StackScreen +import kotlinx.parcelize.Parcelize + +@Parcelize +class AuthContainerScreen( + private val navModel: StackNavModel = StackNavModel(EmailScreen()) +) : StackScreen(navModel) { + + @Composable + override fun Content(modifier: Modifier) { + TopScreenContent(modifier) { modifier -> + ScreenTransition(modifier) + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt new file mode 100644 index 0000000..9d5b438 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt @@ -0,0 +1,79 @@ +package io.github.ikarenkov.workshop.screens.auth + +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.forward +import kotlinx.parcelize.Parcelize + +// TODO: Workshop 7.1 - create first screen for email input +@Parcelize +class EmailScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + val navigation: StackNavContainer = LocalStackNavigation.current + EmailScreenContent( + modifier = modifier, + onContinueClick = { email -> + navigation.forward(AuthCodeScreen()) + } + ) + } +} + +@Composable +internal fun EmailScreenContent( + modifier: Modifier = Modifier, + onContinueClick: (email: String) -> Unit +) { + Column( + modifier.padding(16.dp) + ) { + Text("Authorization and registration", style = MaterialTheme.typography.headlineLarge) + Text("Enter your email to continue", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.weight(1f)) + val (text, setText) = rememberSaveable { mutableStateOf("") } + TextField( + value = text, + onValueChange = setText, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(20.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + onContinueClick(text) + } + ) { + Text("Continue") + } + } +} + +@Preview +@Composable +private fun PreviewEmailScreenContent() { + EmailScreenContent(Modifier.fillMaxSize(), {}) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt new file mode 100644 index 0000000..53726dd --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt @@ -0,0 +1,28 @@ +package io.github.ikarenkov.workshop.screens.auth + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.forward +import kotlinx.parcelize.Parcelize + +@Parcelize +class EmailScreenFinal( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + val navigation: StackNavContainer = LocalStackNavigation.current + EmailScreenContent( + modifier = modifier, + onContinueClick = { email -> + navigation.forward(AuthCodeScreen()) + } + ) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelScreen.kt new file mode 100644 index 0000000..f7d2d23 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelScreen.kt @@ -0,0 +1,196 @@ +package io.github.ikarenkov.workshop.screens.climbing_level + +import androidx.compose.foundation.clickable +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.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.sd.lib.compose.wheel_picker.FVerticalWheelPicker +import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.domain.FrenchScaleGrade +import io.github.ikarenkov.workshop.domain.sportRouteGrades +import io.github.ikarenkov.workshop.screens.profile_setup.SetupStepScreen +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Parcelize +class ClimbingLevelScreen( + val climbingType: ClimbingType, + override val screenKey: ScreenKey = generateScreenKey() +) : SetupStepScreen { + + override val title: String + get() = when (climbingType) { + ClimbingType.Sport -> "Sport climbing level" + ClimbingType.Bouldering -> "Bouldering level" + } + + @Composable + override fun Content(modifier: Modifier) { + // TODO: workshop - ViewModel integration + val viewModel = koinViewModel { + parametersOf(climbingType) + } + val state = viewModel.state.collectAsState() + ClimbingLevelSetupScreenContent( + redpointGrade = state.value.redpointGrade, + onsightGrade = state.value.onsightGrade, + flashGrade = state.value.flashGrade, + setRedpointGrade = viewModel::setRedpointGrade, + setFlashGrade = viewModel::setFlashGrade, + setOnsightGrade = viewModel::setOnsightGrade, + modifier = modifier + ) + } +} + +@Composable +internal fun ClimbingLevelSetupScreenContent( + redpointGrade: FrenchScaleGrade?, + onsightGrade: FrenchScaleGrade?, + flashGrade: FrenchScaleGrade?, + setRedpointGrade: (FrenchScaleGrade) -> Unit, + setOnsightGrade: (FrenchScaleGrade) -> Unit, + setFlashGrade: (FrenchScaleGrade) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + SelectGradeItem( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + grade = redpointGrade, + setGrade = setRedpointGrade, + title = "Max redpoint grade", + description = "Max redpoint is the hardest grade you have climbed without falling or resting on the rope." + ) + + HorizontalDivider(Modifier.fillMaxWidth()) + + SelectGradeItem( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + grade = onsightGrade, + setGrade = setOnsightGrade, + title = "Max onsight grade", + description = "Max onsight grade is the hardest grade you have climbed on the first attempt without any prior knowledge of the route." + ) + + HorizontalDivider(Modifier.fillMaxWidth()) + + SelectGradeItem( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + grade = flashGrade, + setGrade = setFlashGrade, + title = "Max flash grade", + description = "Max flash grade is the hardest grade you have climbed on the first attempt with prior knowledge of the route." + ) + } +} + +@Composable +private fun SelectGradeItem( + grade: FrenchScaleGrade?, + setGrade: (FrenchScaleGrade) -> Unit, + title: String, + description: String, + modifier: Modifier = Modifier, +) { + val (showDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) } + FrenchGradeDialog(grade, setGrade, showDialog, setShowDialog) + Column( + modifier = + Modifier + .clickable { setShowDialog(true) } + .then( + modifier.fillMaxWidth() + ), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = title, style = MaterialTheme.typography.labelLarge) + Text(grade?.toString() ?: "Select grade") + } + Spacer(Modifier.height(10.dp)) + Text(text = description, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun FrenchGradeDialog( + grade: FrenchScaleGrade?, + setGrade: (FrenchScaleGrade) -> Unit, + showDialog: Boolean, + setShowDialog: (Boolean) -> Unit, +) { + val initialPickerPos = remember { grade?.let { sportRouteGrades.indexOf(it) } ?: 0 } + val pickerState = rememberFWheelPickerState(initialPickerPos) + if (showDialog) { + AlertDialog( + onDismissRequest = { setShowDialog(false) }, + title = { Text("Select grade") }, + text = { + FVerticalWheelPicker( + state = pickerState, + count = sportRouteGrades.size + ) { index: Int -> + Text(sportRouteGrades[index].toString()) + } + }, + confirmButton = { + TextButton( + onClick = { + val currentIndex = pickerState.currentIndex + if (currentIndex != -1) { + setGrade(sportRouteGrades[pickerState.currentIndexSnapshot]) + setShowDialog(false) + } + } + ) { + Text("Confirm") + } + }, + dismissButton = { + TextButton( + onClick = { setShowDialog(false) } + ) { + Text("Cancel") + } + } + ) + } +} + +@Preview +@Composable +private fun PreviewClimbingLevelSetup() { + ClimbingLevelSetupScreenContent( + modifier = Modifier.fillMaxSize(), + redpointGrade = FrenchScaleGrade(7, FrenchScaleGrade.Letter.A, true), + setRedpointGrade = {}, + onsightGrade = null, + setFlashGrade = {}, + flashGrade = null, + setOnsightGrade = {} + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelViewModel.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelViewModel.kt new file mode 100644 index 0000000..2210e7f --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelViewModel.kt @@ -0,0 +1,59 @@ +package io.github.ikarenkov.workshop.screens.climbing_level + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.domain.ClimbingLevel +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.domain.FrenchScaleGrade +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class ClimbingLevelViewModel( + private val type: ClimbingType, + private val profileRepository: ClimberProfileRepository +) : ViewModel() { + val state: StateFlow = + profileRepository.climberProfile + .map { profile -> + when (type) { + ClimbingType.Sport -> profile.sportLevel + ClimbingType.Bouldering -> profile.boulderLevel + } + } + .stateIn( + viewModelScope, + SharingStarted.Lazily, + initialValue = profileRepository.climberProfile.value.sportLevel + ) + + fun setRedpointGrade(grade: FrenchScaleGrade) { + profileRepository.updateClimberProfile { + when (type) { + ClimbingType.Sport -> copy(sportLevel = sportLevel.copy(redpointGrade = grade)) + ClimbingType.Bouldering -> copy(boulderLevel = boulderLevel.copy(redpointGrade = grade)) + } + } + } + + fun setOnsightGrade(grade: FrenchScaleGrade) { + profileRepository.updateClimberProfile { + when (type) { + ClimbingType.Sport -> copy(sportLevel = sportLevel.copy(onsightGrade = grade)) + ClimbingType.Bouldering -> copy(boulderLevel = boulderLevel.copy(onsightGrade = grade)) + } + } + } + + fun setFlashGrade(grade: FrenchScaleGrade) { + profileRepository.updateClimberProfile { + when (type) { + ClimbingType.Sport -> copy(sportLevel = sportLevel.copy(flashGrade = grade)) + ClimbingType.Bouldering -> copy(boulderLevel = boulderLevel.copy(flashGrade = grade)) + } + } + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt new file mode 100644 index 0000000..1293fa3 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt @@ -0,0 +1,99 @@ +package io.github.ikarenkov.workshop.screens.main + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.sharp.Home +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +// TODO: Workshop 3.1.1 - create main tab screen inheriting from MultiScreen +// TODO: Workshop 3.1.2 - define initial state in constructor and pass it as argument tu super +// TODO: Workshop 3.1.3 - use @Parcelize annotation to make MainTabScreen class Parcelable +// TODO: Workshop 3.1.4 - navigate to this screen from SampleScreen +// TODO: Workshop 3.2.1 - implement Content function with MainTabContent composable +// TODO: Workshop 3.2.2 - display selected screen inside MainTabContent using SelectedScreen composable +// TODO: Workshop 3.3 - navigate between tabs using selectContainer function +// TODO: Workshop 3.4 - use navigation state to access selected tab position +// TODO: Workshop 3.5 - support animation using build-in SlideTransition + +@Composable +fun MainTabContent( + selectedTabPos: Int, + onTabClick: (Int) -> Unit, + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = modifier, + bottomBar = { + BottomAppBar { + for ((pos, tab) in MainTabs.entries.withIndex()) { + IconButton( + modifier = Modifier.weight(1f), + onClick = { + onTabClick(pos) + }, + ) { + val contentColor = LocalContentColor.current + val color by animateColorAsState( + contentColor.copy( + alpha = if (pos == selectedTabPos) contentColor.alpha else 0.5f + ) + ) + Icon( + rememberVectorPainter(tab.icon), + tint = color, + contentDescription = tab.title + ) + } + } + } + } + ) { paddingValues -> + content(paddingValues) + } +} + +enum class MainTabs( + val icon: ImageVector, + val title: String +) { + HOME(Icons.Sharp.Home, "Home"), + PROFILE(Icons.Default.Face, "Profile") +} + +@Preview +@Composable +private fun PreviewMainTabsContent() { + MainTabContent( + selectedTabPos = 0, + onTabClick = {}, + modifier = Modifier.fillMaxSize() + ) { paddingValues -> + Box( + Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("Selected screen", Modifier.padding(16.dp)) + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreenFinal.kt new file mode 100644 index 0000000..c7e045e --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreenFinal.kt @@ -0,0 +1,48 @@ +package io.github.ikarenkov.workshop.screens.main + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.animation.SlideTransition +import com.github.terrakok.modo.multiscreen.MultiScreen +import com.github.terrakok.modo.multiscreen.MultiScreenNavModel +import com.github.terrakok.modo.multiscreen.selectScreen +import io.github.ikarenkov.workshop.screens.SampleScreenFinal +import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreenFinal +import kotlinx.parcelize.Parcelize + +// Workshop 3.1 - create main tab screen +@Parcelize +class MainTabScreenFinal( +// Workshop 3.1.1 - define initial state + private val navModel: MultiScreenNavModel = MultiScreenNavModel( + SampleScreenFinal(0), + EnhancedProfileScreenFinal(), + selected = 0 + ) +) : MultiScreen(navModel) { + + @Composable + override fun Content(modifier: Modifier) { + MainTabContent( + modifier = modifier, + // Workshop 3.4 - use navigation state to define UI + selectedTabPos = navigationState.selected, + onTabClick = { pos -> + // Workshop 3.3 - navigate between tabs + selectScreen(pos) + } + ) { paddingValues -> + // Workshop 3.2 - display selected screen + SelectedScreen( + Modifier + .padding(paddingValues) + .fillMaxSize() + ) { innerModifier -> + // Workshop 3.5 - support animation using build-in SlideTransition + SlideTransition(innerModifier) + } + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreen.kt new file mode 100644 index 0000000..16d9e9b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreen.kt @@ -0,0 +1,203 @@ +package io.github.ikarenkov.workshop.screens.personal_data + +import android.text.format.DateFormat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.DisplayMode +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import io.github.ikarenkov.workshop.screens.profile_setup.SetupStepScreen +import io.github.ikarenkov.workshop.ui.InputNumRow +import io.github.ikarenkov.workshop.ui.TitleCell +import io.github.ikarenkov.workshop.utils.OnLifecycleEvent +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel +import java.util.Date +import java.util.GregorianCalendar + +@Parcelize +class ClimberPersonalInfoScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : SetupStepScreen { + + override val title: String + get() = "Climbing Profile" + + @Composable + override fun Content(modifier: Modifier) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsState() + val focusRequester = remember { FocusRequester() } + // TODO: Workshop 6.1.1 - get keyboard controller +// val keyboardController = LocalSoftwareKeyboardController.current + OnLifecycleEvent( + onResume = { + // TODO: Workshop 6.1.2 - show the keyboard + }, + onPause = { + // TODO: Workshop 6.1.3 - hide the keyboard + } + ) + ClimberProfileSetupScreenContent( + modifier = modifier, + heightTextFieldModifier = Modifier.focusRequester(focusRequester), + dateOfBirth = state.dateOfBirth, + setDateOfBirth = viewModel::setBirthDate, + height = state.heightSm?.toString().orEmpty(), + setHeight = { height -> + viewModel.setHeight(height.toIntOrNull()) + }, + weight = state.weightKg?.toString().orEmpty(), + setWeight = { weight -> + viewModel.setWeight(weight.toFloatOrNull()) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClimberProfileSetupScreenContent( + dateOfBirth: Date?, + setDateOfBirth: (Date) -> Unit, + height: String, + setHeight: (String) -> Unit, + weight: String, + setWeight: (String) -> Unit, + modifier: Modifier = Modifier, + heightTextFieldModifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + InputNumRow( + title = "Height", + value = height, + onValueChange = setHeight, + valueName = "sm", + modifier = Modifier.fillMaxWidth(), + textFieldModifier = heightTextFieldModifier + ) + InputNumRow( + title = "Weight", + value = weight, + onValueChange = setWeight, + valueName = "kg", + modifier = Modifier.fillMaxWidth() + ) + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = dateOfBirth?.time, + initialDisplayMode = DisplayMode.Input, + ) + TitleDateInput( + title = "Date of Birth", + datePickerState = datePickerState, + onDateConfirmClick = { + datePickerState.selectedDateMillis?.let { + setDateOfBirth(Date()) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TitleDateInput( + title: String, + datePickerState: DatePickerState, + onDateConfirmClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDateDialog by rememberSaveable { mutableStateOf(false) } + if (showDateDialog) { + DatePickerDialog( + onDismissRequest = { showDateDialog = false }, + confirmButton = { + Button( + onClick = { + onDateConfirmClick() + showDateDialog = false + } + ) { + Text("Confirm") + } + } + ) { + DatePicker(datePickerState) + } + } + TitleCell( + modifier = modifier, + title = title, + ) { + val context = LocalContext.current + val dateString by remember { + derivedStateOf { + datePickerState.selectedDateMillis?.let { + DateFormat.getLongDateFormat(context).format(it) + } ?: "Select date" + } + } + TextButton( + onClick = { + showDateDialog = true + } + ) { + Text(text = dateString) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewDateInput() { + TitleDateInput( + title = "Date of Birth", + datePickerState = rememberDatePickerState(), + modifier = Modifier.fillMaxWidth(), + onDateConfirmClick = {} + ) +} + +@Preview +@Composable +private fun PreviewScreen() { + ClimberProfileSetupScreenContent( + dateOfBirth = GregorianCalendar(1997, 1, 5).time, + setDateOfBirth = {}, + height = "180", + setHeight = {}, + weight = "70", + setWeight = {}, + modifier = Modifier.fillMaxSize() + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreenFinal.kt new file mode 100644 index 0000000..ad75b4b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreenFinal.kt @@ -0,0 +1,69 @@ +package io.github.ikarenkov.workshop.screens.personal_data + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import io.github.ikarenkov.workshop.screens.profile_setup.SetupStepScreen +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel + +@Parcelize +class ClimberPersonalInfoScreenFinal( + override val screenKey: ScreenKey = generateScreenKey() +) : SetupStepScreen { + + override val title: String + get() = "Climbing Profile" + + @Composable + override fun Content(modifier: Modifier) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsState() + val focusRequester = remember { FocusRequester() } + val lifecycleOwner = LocalLifecycleOwner.current + val keyboardController = LocalSoftwareKeyboardController.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + focusRequester.requestFocus() + } + Lifecycle.Event.ON_PAUSE -> { + keyboardController?.hide() + focusRequester.freeFocus() + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + ClimberProfileSetupScreenContent( + modifier = modifier, + heightTextFieldModifier = Modifier.focusRequester(focusRequester), + dateOfBirth = state.dateOfBirth, + setDateOfBirth = viewModel::setBirthDate, + height = state.heightSm?.toString().orEmpty(), + setHeight = { height -> + viewModel.setHeight(height.toIntOrNull()) + }, + weight = state.weightKg?.toString().orEmpty(), + setWeight = { weight -> + viewModel.setWeight(weight.toFloatOrNull()) + }, + ) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoViewModel.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoViewModel.kt new file mode 100644 index 0000000..5e1e76e --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoViewModel.kt @@ -0,0 +1,32 @@ +package io.github.ikarenkov.workshop.screens.personal_data + +import androidx.lifecycle.ViewModel +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.domain.ClimberProfile +import kotlinx.coroutines.flow.StateFlow +import java.util.Date + +class ClimberPersonalInfoViewModel( + private val climberProfileRepository: ClimberProfileRepository +) : ViewModel() { + + val state: StateFlow = climberProfileRepository.climberProfile + + fun setHeight(height: Int?) { + climberProfileRepository.updateClimberProfile { + copy(heightSm = height) + } + } + + fun setWeight(weight: Float?) { + climberProfileRepository.updateClimberProfile { + copy(weightKg = weight) + } + } + + fun setBirthDate(date: Date) { + climberProfileRepository.updateClimberProfile { + copy(dateOfBirth = date) + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt new file mode 100644 index 0000000..03cb0c7 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt @@ -0,0 +1,162 @@ +package io.github.ikarenkov.workshop.screens.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.ContainerScreen +import com.github.terrakok.modo.NavModel +import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationState +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.forward +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.screens.climbing_level.ClimbingLevelScreen +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreen +import io.github.ikarenkov.workshop.screens.profile_setup.ProfileSetupFlowScreenFinal +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel + +@Parcelize +class EnhancedProfileScreen( + private val navModel: NavModel = NavModel( + // TODO: Workshop 6.2.4 - set initial state + EnhancedProfileNavigationState( + ClimberPersonalInfoScreen(), + ClimbingLevelScreen(ClimbingType.Sport), + ClimbingLevelScreen(ClimbingType.Bouldering), + ) + ) +// TODO: Workshop 6.2.1 - inherit from ContainerScreen +) : ContainerScreen( + navModel +) { + + @Composable + override fun Content(modifier: Modifier) { + val navigation = LocalStackNavigation.current + val viewModel = koinViewModel() + val state by viewModel.state.collectAsState() + EnhancedProfileContent( + name = state.name.orEmpty(), + description = state.description.orEmpty(), + finishedClimbingSetup = state.finishedClimbingSetup, + onSetupProfileClick = { restart -> + navigation.forward(ProfileSetupFlowScreenFinal(restart)) + }, + onViewInsightsClick = { + // TODO: Workshop 6.3 - OpenDialog + }, + modifier = modifier + ) { + // TODO: Workshop 6.2.5 - display screens inside LazyList using build-in fun screenItem and InternalContent(screen) + } + } +} + +@Composable +fun EnhancedProfileContent( + name: String, + description: String, + finishedClimbingSetup: Boolean, + onSetupProfileClick: (restart: Boolean) -> Unit, + onViewInsightsClick: () -> Unit, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit = {} +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + ProfileHeader(name, description) + } + item { + Card { + Column( + Modifier.padding(16.dp) + ) { + if (finishedClimbingSetup) { + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = onViewInsightsClick + ) { + Text("View insights") + } + } + if (finishedClimbingSetup) { + OutlinedButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + onSetupProfileClick(true) + }, + ) { + Text("Restart insights setup") + } + } else { + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + onSetupProfileClick(false) + }, + ) { + Text("Get climbing insights") + } + } + } + } + } + content() + } +} + +// TODO: Workshop 6.2.2 - define navigation state +@Parcelize +data class EnhancedProfileNavigationState( + val climbingProfileScreen: ClimberPersonalInfoScreen? = null, + val sportLevelScreen: ClimbingLevelScreen? = null, + val boulderingLevelScreen: ClimbingLevelScreen? = null, +) : NavigationState { + + override fun getChildScreens(): List = listOfNotNull( + climbingProfileScreen, + sportLevelScreen, + boulderingLevelScreen + ) + +} + +// TODO: Workshop 6.2.3 - define navigation action +class EnhancedProfileNavigationActionNoOp : NavigationAction + +@Preview +@Composable +private fun PreviewEnhancedProfile() { + EnhancedProfileContent( + name = "Igor Karenkov", + description = "Software Engineer", + modifier = Modifier.fillMaxSize(), + finishedClimbingSetup = true, + onSetupProfileClick = {}, + onViewInsightsClick = {} + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt new file mode 100644 index 0000000..c678bfa --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt @@ -0,0 +1,68 @@ +package io.github.ikarenkov.workshop.screens.profile + +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.ContainerScreen +import com.github.terrakok.modo.NavModel +import com.github.terrakok.modo.lazylist.screenItem +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.forward +import io.github.ikarenkov.workshop.screens.TrainingRecommendationsDialogScreenFinal +import io.github.ikarenkov.workshop.screens.profile_setup.ProfileSetupFlowScreenFinal +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Parcelize +class EnhancedProfileScreenFinal( + private val navModel: NavModel = NavModel(EnhancedProfileNavigationState()) +) : ContainerScreen( + navModel +) { + + @Composable + override fun Content(modifier: Modifier) { + val navigation = LocalStackNavigation.current + val viewModel = koinViewModel { + parametersOf(this) + } + val state by viewModel.state.collectAsState() + EnhancedProfileContent( + name = state.name.orEmpty(), + description = state.description.orEmpty(), + finishedClimbingSetup = state.finishedClimbingSetup, + onSetupProfileClick = { restart -> + navigation.forward(ProfileSetupFlowScreenFinal(restart)) + }, + onViewInsightsClick = { + navigation.forward(TrainingRecommendationsDialogScreenFinal()) + }, + modifier = modifier + ) { + navigationState.climbingProfileScreen?.let { screen -> + screenItem(screen) { + Card { + InternalContent(screen) + } + } + } + navigationState.sportLevelScreen?.let { screen -> + screenItem(screen) { + Card { + InternalContent(screen) + } + } + } + navigationState.boulderingLevelScreen?.let { screen -> + screenItem(screen) { + Card { + InternalContent(screen) + } + } + } + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt new file mode 100644 index 0000000..495552f --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt @@ -0,0 +1,30 @@ +package io.github.ikarenkov.workshop.screens.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.ikarenkov.workshop.core.mapStateFlow +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.domain.ClimberProfile +import kotlinx.coroutines.flow.StateFlow + +class EnhancedProfileViewModel( + private val climberProfileRepository: ClimberProfileRepository +) : ViewModel() { + + val state: StateFlow = climberProfileRepository.climberProfile + .mapStateFlow(viewModelScope) { + it.toUiState() + } + + private fun ClimberProfile.toUiState(): UiState = UiState( + name = name, + description = description, + finishedClimbingSetup = boulderLevel.hasAllGrades() && sportLevel.hasAllGrades() + ) + + data class UiState( + val name: String?, + val description: String?, + val finishedClimbingSetup: Boolean + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt new file mode 100644 index 0000000..2bac713 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt @@ -0,0 +1,75 @@ +package io.github.ikarenkov.workshop.screens.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.terrakok.modo.ReducerAction +import io.github.ikarenkov.workshop.core.mapStateFlow +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.domain.ClimberProfile +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.screens.climbing_level.ClimbingLevelScreen +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreen +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class EnhancedProfileViewModelFinal( + private val enhancedProfileScreenFinal: EnhancedProfileScreenFinal, + private val climberProfileRepository: ClimberProfileRepository +) : ViewModel() { + + val state: StateFlow = climberProfileRepository.climberProfile + .mapStateFlow(viewModelScope) { + it.toUiState() + } + + init { + viewModelScope.launch { + climberProfileRepository.climberProfile.collect { profile -> + enhancedProfileScreenFinal.dispatch( + EnhancedProfileNavigationAction( + showClimberProfile = profile.dateOfBirth != null, + showBoulderLever = profile.boulderLevel.hasAllGrades(), + showLeadLevel = profile.sportLevel.hasAllGrades() + ) + ) + } + } + } + + private fun ClimberProfile.toUiState(): UiState = UiState( + name = name, + description = description, + finishedClimbingSetup = boulderLevel.hasAllGrades() && sportLevel.hasAllGrades() + ) + + data class UiState( + val name: String?, + val description: String?, + val finishedClimbingSetup: Boolean + ) +} + +class EnhancedProfileNavigationAction( + private val showClimberProfile: Boolean, + private val showLeadLevel: Boolean, + private val showBoulderLever: Boolean, +) : ReducerAction { + override fun reduce(oldState: EnhancedProfileNavigationState): EnhancedProfileNavigationState = oldState.copy( + climbingProfileScreen = if (showClimberProfile) { + oldState.climbingProfileScreen ?: ClimberPersonalInfoScreen() + } else { + null + }, + sportLevelScreen = if (showLeadLevel) { + oldState.sportLevelScreen ?: ClimbingLevelScreen(ClimbingType.Sport) + } else { + null + }, + boulderingLevelScreen = if (showBoulderLever) { + oldState.boulderingLevelScreen ?: ClimbingLevelScreen(ClimbingType.Bouldering) + } else { + null + } + ) + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..7a809b0 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt @@ -0,0 +1,113 @@ +package io.github.ikarenkov.workshop.screens.profile + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import kotlinx.parcelize.Parcelize + +@Parcelize +class ProfileScreen( + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + ProfileScreenContent( + name = "Igor Karenkov", + description = "Software Engineer", + modifier = modifier, + onSetupProfileClick = { + // TODO: Workshop 4.2 - navigate to ProfileSetupFlowScreen + } + ) + } +} + +@Composable +internal fun ProfileScreenContent( + name: String, + description: String, + onSetupProfileClick: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier, contentPadding = PaddingValues(16.dp)) { + item { + ProfileHeader( + name = name, + description = description + ) + } + item { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onSetupProfileClick + ) { + Text("Get training insights") + } + } + } +} + +@Composable +fun ProfileHeader( + name: String, + description: String, + modifier: Modifier = Modifier +) { + Card( + modifier.fillMaxWidth() + ) { + Row(Modifier.padding(16.dp)) { + Icon( + rememberVectorPainter(image = Icons.Default.Face), + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .border(1.dp, Color.Gray, CircleShape), + contentDescription = "Avatar" + ) + Spacer(Modifier.width(16.dp)) + Column { + Text(name, style = MaterialTheme.typography.titleLarge) + Text(description, style = MaterialTheme.typography.labelMedium) + } + } + } +} + +@Preview +@Composable +private fun PreviewProfile() { + ProfileScreenContent( + name = "Igor Karenkov", + description = "Software Engineer", + modifier = Modifier.fillMaxSize(), + onSetupProfileClick = {} + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt new file mode 100644 index 0000000..2e000b2 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt @@ -0,0 +1,218 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import io.github.ikarenkov.workshop.screens.TrainingRecommendationsContent +import io.github.ikarenkov.workshop.ui.progress.ProgressBar +import kotlinx.parcelize.Parcelize + +@Parcelize +class ProfileSetupFlowScreen( +// TODO: Workshop 4.1.1 - implement navModel in constructor and pass it to StackScreen, use ClimberPersonalInfoScreen as initial screen + override val screenKey: ScreenKey = generateScreenKey() +) : Screen { + + @Composable + override fun Content(modifier: Modifier) { + // TODO: Workshop 5.1.2 - retrieve viewModel using koinViewModel + // TODO: Workshop 5.1.5 - pass parameters to viewModel + // TODO: Workshop 4.4 - use navigation state to retrieve current step and title + val state = ProfileSetupContainerUiState( + // TODO: Workshop 4.4.1 - retrieve title from last screen when it is SetupStepScreen + title = "Step #1", + // TODO: Workshop 4.4.2 - set current step based on size of stack + currentStep = 1, + stepsCount = 4, + continueEnabled = true + ) + ProfileSetupFlowContainerContent( + modifier = modifier, + // TODO: Workshop 5.3.2 - use state from viewModel + state = state, + onContinueClick = { + // TODO: Workshop 4.3.1 - navigation based on selected screen. Use `getNextProfileSetupStepScreen`. + // TODO: 5.2.1 - move to VM + }, + onCancelClick = { + // TODO: Workshop 4.3.2 - navigate back + // TODO: 5.2.2 - move to VM + }, + onBackClick = { + // TODO: Workshop 4.3.3 - pass navigation to parent + // TODO: 5.2.3 - move to VM + }, + ) { modifier -> + // TODO: Workshop 4.1.2 - display content + // TODO: Workshop 4.5 - custom animation + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileSetupFlowContainerContent( + state: ProfileSetupContainerUiState, + onBackClick: () -> Unit, + onCancelClick: () -> Unit, + onContinueClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (Modifier) -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Column { + AnimatedContent( + targetState = state.title, + label = "Title animation", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { title -> + Text( + modifier = Modifier.fillMaxWidth(), + text = title + ) + } + Spacer(Modifier.height(10.dp)) + ProgressBar( + modifier = Modifier.fillMaxWidth(), + step = state.currentStep, + stepsCount = state.stepsCount + ) + } + }, + navigationIcon = { + IconButton( + onClick = onBackClick + ) { + Icon( + painter = rememberVectorPainter(Icons.AutoMirrored.Filled.ArrowBack), + contentDescription = "Back" + ) + } + }, + actions = { + IconButton( + onClick = onCancelClick + ) { + Icon( + painter = rememberVectorPainter(Icons.Filled.Close), + contentDescription = "Exit profile setup" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box(Modifier.weight(1f)) { + content(Modifier.fillMaxHeight()) + } + Button( + onClick = onContinueClick, + enabled = state.continueEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text("Continue") + } + } + } +} + +data class ProfileSetupContainerUiState( + val title: String, + val continueEnabled: Boolean, + val stepsCount: Int, + val currentStep: Int +) + +@Preview +@Composable +internal fun PreviewProfileSetupContainer() { + MaterialTheme { + ProfileSetupFlowContainerContent( + state = ProfileSetupContainerUiState( + title = "Step #1", + currentStep = 1, + stepsCount = 4, + continueEnabled = true + ), + modifier = Modifier.fillMaxSize(), + onBackClick = {}, + onCancelClick = {}, + onContinueClick = {}, + ) { modifier -> + Column( + modifier + .background(Color.Gray) + .fillMaxWidth() + ) { + Text("Content") + } + } + } +} + +@Preview +@Composable +internal fun PreviewProfileSetupFinal() { + MaterialTheme { + ProfileSetupFlowContainerContent( + state = ProfileSetupContainerUiState( + title = "Step #1", + currentStep = 1, + stepsCount = 4, + continueEnabled = true + ), + modifier = Modifier.fillMaxSize(), + onBackClick = {}, + onCancelClick = {}, + onContinueClick = {}, + ) { modifier -> + Box { + TrainingRecommendationsContent(modifier.fillMaxHeight()) + } + } + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreenFinal.kt new file mode 100644 index 0000000..899d35f --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreenFinal.kt @@ -0,0 +1,65 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.animation.SlideTransition +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.StackNavModel +import com.github.terrakok.modo.stack.StackScreen +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreenFinal +import kotlinx.parcelize.Parcelize +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +// Workshop 4.1 - custom container +@OptIn(ExperimentalModoApi::class) +@Parcelize +class ProfileSetupFlowScreenFinal( + private val restartFlow: Boolean = false, + // Workshop 4.1.1 - stack nav model with initial screen + private val navModel: StackNavModel = StackNavModel(ClimberPersonalInfoScreenFinal()) +) : StackScreen(navModel) { + + // FIXME +// @ExperimentalModoApi +// override fun provideDialogPlaceholderScreen(): DialogScreen? = null + + @Suppress("MagicNumber") + @Composable + override fun Content(modifier: Modifier) { + val parentNavigation = LocalStackNavigation.current + // Workshop 5.1.3 - pass parameters + val viewModel = koinViewModel { parametersOf(restartFlow, this, parentNavigation) } + val state by viewModel.state.collectAsState() + ProfileSetupFlowContainerContent( + modifier = modifier, + state = state, + onContinueClick = { + // Workshop 4.2.1 - navigation based on selected screen + // Workshop 5.2.1 - move to VM + viewModel.onContinueClick() + }, + onCancelClick = { + // Workshop 4.2.2 - navigate back + // Workshop 5.2.3 - move to VM + viewModel.onCancelClick() + }, + onBackClick = { + // Workshop 4.2.3 - pass navigation to parent + // Workshop 5.2.3 - move to VM + viewModel.onBackClick() + } + ) { contentModifier -> + // Workshop 4.1.2 - display content + TopScreenContent(contentModifier) { transitionModifier -> + // Workshop 4.3 - custom animation + SlideTransition(transitionModifier) + } + } + + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModel.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModel.kt new file mode 100644 index 0000000..287139c --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModel.kt @@ -0,0 +1,74 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.lifecycle.ViewModel +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.stack.StackState +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import io.github.ikarenkov.workshop.domain.ClimberProfile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// TODO: Workshop 5.1 - use VM +class ProfileSetupFlowViewModel( + // TODO: Workshop 5.1.3 - pass ProfileSetupFlowScreen and parent stack navigation as parameters +// private val profileSetupFlowScreen: ProfileSetupFlowScreen, +// private val parentNavigation: StackNavContainer, + @Suppress("UnusedPrivateProperty") + private val climberProfileRepository: ClimberProfileRepository, +) : ViewModel() { + + // TODO: Workshop 5.4 - set navigation initial state using getProfileSetupStartingStep and getProfileSetupInitialScreens + + // TODO: Workshop 5.3.1 - define state using navigationStateFlow and climberProfileRepository.climberProfile + // Use combineStateFlow, navigationStateStateFlow, climberProfileRepository.climberProfile and getUiState +// val state: StateFlow = combineStateFlow( +// profileSetupFlowScreen.navigationStateStateFlow(viewModelScope), +// climberProfileRepository.climberProfile, +// viewModelScope +// ) { navigationState, profile -> +// getUiState(navigationState, profile) +// } + + fun onContinueClick() { + // TODO: Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen +// getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size) +// ?.let { profileSetupFlowScreen.forward(it) } +// ?: parentNavigation.back() + } + + fun onCancelClick() { + // TODO: Workshop 5.2.2 - move onCancelClick from ProfileSetupFlowScreen +// parentNavigation.back() + } + + fun onBackClick() { + // TODO: Workshop 5.2.3 - move onBackClick from ProfileSetupFlowScreen +// if (profileSetupFlowScreen.navigationState.stack.size > 1) { +// profileSetupFlowScreen.back() +// } else { +// parentNavigation.back() +// } + } + +} + +fun getUiState( + navigationState: StackState, + profile: ClimberProfile +) = ProfileSetupContainerUiState( + continueEnabled = isContinueEnabled(navigationState.stack.size, profile), + currentStep = navigationState.stack.size, + stepsCount = 4, + title = navigationState.stack.lastOrNull()?.let { it as? SetupStepScreen }?.title ?: "Profile Setup" +) + +@Suppress("MagicNumber") +fun isContinueEnabled( + currentStep: Int, + profile: ClimberProfile +) = when (currentStep) { + 1 -> profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null + 2 -> profile.sportLevel.hasAllGrades() + 3 -> profile.boulderLevel.hasAllGrades() + else -> true +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt new file mode 100644 index 0000000..5562763 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt @@ -0,0 +1,61 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.terrakok.modo.navigationStateStateFlow +import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackState +import com.github.terrakok.modo.stack.back +import com.github.terrakok.modo.stack.forward +import com.github.terrakok.modo.stack.setState +import io.github.ikarenkov.workshop.core.combineStateFlow +import io.github.ikarenkov.workshop.data.ClimberProfileRepository +import kotlinx.coroutines.flow.StateFlow + +// Workshop 5.1 - create VM +@Suppress("MagicNumber") +class ProfileSetupFlowViewModelFinal( + private val restartFlow: Boolean, + // Workshop 5.1.1 - take screens as parametrs + private val profileSetupFlowScreen: ProfileSetupFlowScreenFinal, + private val parentNavigation: StackNavContainer, + private val climberProfileRepository: ClimberProfileRepository, +) : ViewModel() { + + init { + val startStep = + if (restartFlow) 1 else getProfileSetupStartingStep(climberProfileRepository.climberProfile.value) + profileSetupFlowScreen.setState(StackState(getProfileSetupInitialScreens(startStep))) + } + + // Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile + val state: StateFlow = combineStateFlow( + profileSetupFlowScreen.navigationStateStateFlow(viewModelScope), + climberProfileRepository.climberProfile, + viewModelScope, + ) { navigationState, profile -> + getUiState(navigationState, profile) + } + + // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen + fun onContinueClick() { + getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size)?.let { + profileSetupFlowScreen.forward(it) + } ?: parentNavigation.back() + } + + // Workshop 5.2.2 - move onCancelClick from ProfileSetupFlowScreen + fun onCancelClick() { + parentNavigation.back() + } + + // Workshop 5.2.3 - move onBackClick from ProfileSetupFlowScreen + fun onBackClick() { + if (profileSetupFlowScreen.navigationState.stack.size > 1) { + profileSetupFlowScreen.back() + } else { + parentNavigation.back() + } + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupLogic.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupLogic.kt new file mode 100644 index 0000000..63b2aed --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupLogic.kt @@ -0,0 +1,31 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import io.github.ikarenkov.workshop.domain.ClimberProfile +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.screens.TrainingRecommendationsScreen +import io.github.ikarenkov.workshop.screens.climbing_level.ClimbingLevelScreen +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreenFinal + +@Suppress("MagicNumber") +fun getNextProfileSetupStepScreen(step: Int): SetupStepScreen? = when (step) { + 1 -> ClimbingLevelScreen(ClimbingType.Sport) + 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) + 3 -> TrainingRecommendationsScreen() + else -> null +} + +@Suppress("MagicNumber") +fun getProfileSetupInitialScreens(step: Int) = listOfNotNull( + ClimberPersonalInfoScreenFinal(), + if (step >= 2) ClimbingLevelScreen(ClimbingType.Sport) else null, + if (step >= 3) ClimbingLevelScreen(ClimbingType.Bouldering) else null, + if (step >= 4) TrainingRecommendationsScreen() else null +) + +@Suppress("MagicNumber") +fun getProfileSetupStartingStep(profile: ClimberProfile) = when { + profile.boulderLevel.hasAllGrades() -> 4 + profile.sportLevel.hasAllGrades() -> 3 + profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null -> 2 + else -> 1 +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/SetupStepScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/SetupStepScreen.kt new file mode 100644 index 0000000..dad0990 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/SetupStepScreen.kt @@ -0,0 +1,7 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import com.github.terrakok.modo.Screen + +interface SetupStepScreen : Screen { + val title: String +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/CommonUi.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/CommonUi.kt new file mode 100644 index 0000000..7717b09 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/CommonUi.kt @@ -0,0 +1,95 @@ +package io.github.ikarenkov.workshop.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun TitleCell( + title: String, + modifier: Modifier = Modifier, + @SuppressLint("ComposableLambdaParameterNaming") contentRight: @Composable () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.labelLarge) + contentRight() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputNumRow( + title: String, + value: String, + onValueChange: (String) -> Unit, + valueName: String, + modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier +) { + TitleCell(title, modifier) { + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number + ), + modifier = textFieldModifier.width(100.dp), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End) + ) { + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = it, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 8.dp + ), + suffix = { + Text(valueName) + } + ) + } + } +} + +@Preview +@Composable +private fun PreviewInputStringRow() { + InputNumRow( + title = "Height", + value = "180", + onValueChange = {}, + valueName = "sm", + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBar.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBar.kt new file mode 100644 index 0000000..88e67bf --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBar.kt @@ -0,0 +1,161 @@ +package io.github.ikarenkov.workshop.ui.progress + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.LocalDensity + +private const val DEFAULT_STEPS_COUNT = 6 +private const val COLOR_COUNT = 3 +private const val MIN_STEP_COUNT = 3 + +private const val MAX_PROGRESS = 100 +private const val ANIMATION_DURATION = 500 + +/** + * @param step Текущий шаг + * @param stepsCount Общее количество шагов + * @param type Тип. Шаги [ProgressBarType.STEP] или проценты [ProgressBarType.PERCENT] + * @param colors Цвета. Для стандартного прогрессбара (красный, оранжевый, зеленый), захардкожены внутри функции + * @param sizes Размеры + * @param withAnimation Анимация прогресса и смены цветов + * @param onAnimationFinish Коллбэк окончания анимации + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun ProgressBar( + modifier: Modifier = Modifier, + step: Int = 0, + stepsCount: Int = DEFAULT_STEPS_COUNT, + type: ProgressBarType = ProgressBarType.STEP, + colors: ProgressBarColors = ProgressBarColors.Default, + sizes: ProgressBarSizes = ProgressBarSizes.Default, + withAnimation: Boolean = false, + onAnimationFinish: (() -> Unit)? = null, +) { + val normalizedStepsCount = if (stepsCount < MIN_STEP_COUNT) MIN_STEP_COUNT else stepsCount + + val maxValue = when (type) { + ProgressBarType.STEP -> normalizedStepsCount + ProgressBarType.PERCENT -> step / (MAX_PROGRESS / normalizedStepsCount) // % -> уменьшать в меньшую сторону + } + + val normalizedStep = step.coerceAtMost(maxValue).coerceAtLeast(0) + val colorStep = normalizedStepsCount / COLOR_COUNT + val progress = normalizedStep / normalizedStepsCount.toFloat() + val internalProgressColor = colors.progressColor ?: run { + when { + normalizedStep <= colorStep -> MaterialTheme.colorScheme.error + normalizedStep in colorStep + 1..colorStep * 2 -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.primary + } + } + + val animatedProgress = if (withAnimation) { + animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = ANIMATION_DURATION), + finishedListener = { onAnimationFinish?.invoke() }, + ).value + } else { + progress + } + + val animatedProgressColor = if (withAnimation) { + animateColorAsState( + targetValue = internalProgressColor, + animationSpec = tween(durationMillis = ANIMATION_DURATION), + ).value + } else { + internalProgressColor + } + + val delimiterWidthPx = with(LocalDensity.current) { + sizes.delimiterWidth.toPx() + } + + Canvas( + modifier = Modifier + .height(sizes.progressBarHeight) + .fillMaxWidth() + .clip(RoundedCornerShape(sizes.progressBarHeight / 2)) // закругления на концах прогрессбара + .then(modifier), + ) { + val heightF = size.height + val widthF = size.width + val progressWidth = widthF * animatedProgress + + val delimitersPath = Path().apply { + @Suppress("ForEachOnRange") + (1 until normalizedStepsCount).forEach { step -> + val delimiterLeft = widthF * (step.toFloat() / normalizedStepsCount) - delimiterWidthPx / 2 + + addRect( + Rect(Offset(x = delimiterLeft, y = 0f), Size(width = delimiterWidthPx, height = heightF)), + ) + } + } + + if (colors.delimiterColor == null) { + // вырезаем разделители из прогресса + clipPath(path = delimitersPath, clipOp = ClipOp.Difference) { + drawProgressBar( + progressWidth = progressWidth, + emptyProgressWidth = widthF - progressWidth, + heightF = widthF, + animatedProgressColor = animatedProgressColor, + emptyProgressColor = colors.emptyProgressColor, + ) + } + } else { + drawProgressBar( + progressWidth = progressWidth, + emptyProgressWidth = widthF - progressWidth, + heightF = widthF, + animatedProgressColor = animatedProgressColor, + emptyProgressColor = colors.emptyProgressColor, + ) + + // рисуем разделители на прогрессе + drawPath(path = delimitersPath, color = colors.delimiterColor) + } + } +} + +private fun DrawScope.drawProgressBar( + progressWidth: Float, + emptyProgressWidth: Float, + heightF: Float, + animatedProgressColor: Color, + emptyProgressColor: Color, +) { + // рисуем цветной прогресс + drawRect( + color = animatedProgressColor, + topLeft = Offset(x = 0f, y = 0f), + size = Size(width = progressWidth, height = heightF), + ) + + // рисуем серенький остаток + drawRect( + color = emptyProgressColor, + topLeft = Offset(x = progressWidth, y = 0f), + size = Size(width = emptyProgressWidth, height = heightF), + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarColors.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarColors.kt new file mode 100644 index 0000000..efba75f --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarColors.kt @@ -0,0 +1,36 @@ +package io.github.ikarenkov.workshop.ui.progress + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +/** + * Описание цветов прогрессбара + * + * @param progressColor Цвет заполнения прогресса, по-умолчанию null - 3х-цветный + * @param delimiterColor Цвет разделителей между шагами, по-умолчанию null. + * Разделитель цвета фона родителя, те прозрачный + * @param emptyProgressColor Цвет пустого (незаполненного) прогрессбара + */ +@Stable +data class ProgressBarColors( + val progressColor: Color?, + val delimiterColor: Color?, + val emptyProgressColor: Color, +) { + + companion object { + + @Stable + val Default: ProgressBarColors + @Composable + get() = ProgressBarColors( + progressColor = null, + delimiterColor = null, + emptyProgressColor = MaterialTheme.colorScheme.background, + ) + + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarSizes.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarSizes.kt new file mode 100644 index 0000000..eb94d77 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarSizes.kt @@ -0,0 +1,33 @@ +package io.github.ikarenkov.workshop.ui.progress + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Описание размеров прогрессбара + * + * @param progressBarHeight Высота прогрессбара + * @param delimiterWidth Ширина разделителя между шагами + */ +@Immutable +data class ProgressBarSizes( + val progressBarHeight: Dp, + val delimiterWidth: Dp, +) { + + companion object { + + @Stable + val Default: ProgressBarSizes + @Composable + get() = ProgressBarSizes( + progressBarHeight = 8.dp, + delimiterWidth = 2.dp, + ) + + } + +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarType.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarType.kt new file mode 100644 index 0000000..9e15427 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarType.kt @@ -0,0 +1,6 @@ +package io.github.ikarenkov.workshop.ui.progress + +enum class ProgressBarType { + STEP, + PERCENT +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/utils/LifecycleUtils.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/utils/LifecycleUtils.kt new file mode 100644 index 0000000..f3c6f6c --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/utils/LifecycleUtils.kt @@ -0,0 +1,59 @@ +package io.github.ikarenkov.workshop.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Subscribes to the lifecycle of the nearest [LocalLifecycleOwner] using [DisposableEffect] and + * calls the corresponding function for each lifecycle event. + * @param onAny - use this if you want to define your own logic based on [Lifecycle.Event]. + */ +@Composable +fun OnLifecycleEvent( + onCreate: (() -> Unit)? = null, + onStart: (() -> Unit)? = null, + onResume: (() -> Unit)? = null, + onPause: (() -> Unit)? = null, + onStop: (() -> Unit)? = null, + onDestroy: (() -> Unit)? = null, + onAny: ((Lifecycle.Event) -> Unit)? = null, +) { + // Remember updated state for each lambda to handle state changes correctly + val currentOnCreate by rememberUpdatedState(onCreate) + val currentOnStart by rememberUpdatedState(onStart) + val currentOnResume by rememberUpdatedState(onResume) + val currentOnPause by rememberUpdatedState(onPause) + val currentOnStop by rememberUpdatedState(onStop) + val currentOnDestroy by rememberUpdatedState(onDestroy) + val currentOnAny by rememberUpdatedState(onAny) + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + // Invoke the corresponding lambda if it's not null + when (event) { + Lifecycle.Event.ON_CREATE -> currentOnCreate?.invoke() + Lifecycle.Event.ON_START -> currentOnStart?.invoke() + Lifecycle.Event.ON_RESUME -> currentOnResume?.invoke() + Lifecycle.Event.ON_PAUSE -> currentOnPause?.invoke() + Lifecycle.Event.ON_STOP -> currentOnStop?.invoke() + Lifecycle.Event.ON_DESTROY -> currentOnDestroy?.invoke() + else -> {} + } + // Invoke onAny if provided + currentOnAny?.invoke(event) + } + // Add the observer to the lifecycle + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + // Remove the observer when the composable is disposed + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} diff --git a/workshop-app/src/main/res/values-v23/themes.xml b/workshop-app/src/main/res/values-v23/themes.xml new file mode 100644 index 0000000..cde1841 --- /dev/null +++ b/workshop-app/src/main/res/values-v23/themes.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/workshop-app/src/main/res/values/themes.xml b/workshop-app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a6cda57 --- /dev/null +++ b/workshop-app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file