From 6646d14942e6fe43e1456bbdc8599fd0ba05fdfd Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Mon, 1 Jul 2024 13:54:16 +0200 Subject: [PATCH 01/36] Fixed Lifecycle support: now you can use DisposableEffect to observe all lifecycle events --- TestInstructions.md | 14 ++ gradle/libs.versions.toml | 2 + .../com/github/terrakok/modo/ComposeRender.kt | 133 +++++++++++++----- .../github/terrakok/modo/ModoDevOptions.kt | 4 + .../modo/android/ModoScreenAndroidAdapter.kt | 49 +++++-- .../modo/animation/ScreenTransitions.kt | 11 +- .../modo/lifecycle/LifecycleDependency.kt | 11 ++ .../github/terrakok/modo/model/ScreenExt.kt | 6 + .../terrakok/modo/model/ScreenModelStore.kt | 38 +++-- .../github/terrakok/modo/stack/StackScreen.kt | 6 - sample/build.gradle.kts | 1 + .../github/terrakok/modo/sample/Animations.kt | 5 +- .../modo/sample/ModoSampleApplication.kt | 4 + .../modo/sample/screens/MainScreen.kt | 28 ++-- .../screens/ScreenEffectsSampleScreen.kt | 53 ++++++- .../screens/base/ButtonsScreenContent.kt | 38 ++++- .../screens/containers/CustomStackSample.kt | 11 +- .../screens/containers/custom/InnerScreen.kt | 8 +- .../custom/SampleCustomContainerScreen.kt | 9 +- .../screens/dialogs/DialogsPlayground.kt | 7 +- .../sample/screens/dialogs/M3BottomSheet.kt | 1 - .../sample/screens/dialogs/SampleDialog.kt | 3 +- .../SystemDialogWithCustomDimSample.kt | 2 +- .../screens/stack/StackActionsScreen.kt | 4 +- .../viewmodel/AndroidViewModelSampleScreen.kt | 51 +------ 25 files changed, 317 insertions(+), 182 deletions(-) create mode 100644 TestInstructions.md create mode 100644 modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt create mode 100644 modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt diff --git a/TestInstructions.md b/TestInstructions.md new file mode 100644 index 0000000..22ae5c8 --- /dev/null +++ b/TestInstructions.md @@ -0,0 +1,14 @@ +# 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 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51df6c3..b7f54fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,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" } 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..b909437 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 @@ -1,20 +1,25 @@ package com.github.terrakok.modo +import android.util.Log 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 com.github.terrakok.modo.android.ModoScreenAndroidAdapter -import com.github.terrakok.modo.animation.displayingScreens +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 +27,58 @@ 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. */ -val LocalTransitionCompleteChannel = staticCompositionLocalOf> { error("no channel provided") } - @Composable fun Screen.SaveableContent(modifier: Modifier = Modifier) { 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 - } - } + 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 +108,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) { @@ -101,24 +128,21 @@ 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}") - clearScreens(stateHolder) - } + + 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 { + { afterScreenContentOnDispose() } } + CompositionLocalProvider( LocalContainerScreen provides containerScreen, - LocalTransitionCompleteChannel provides transitionCompleteChannel, + LocalBeforeScreenContentOnDispose provides beforeScreenContentOnDispose, + LocalAfterScreenContentOnDispose provides afterScreenContentOnDispose, *provideCompositionLocal ) { ComposeRendererScope(lastState, state, screen).content(modifier) @@ -136,7 +160,7 @@ internal class ComposeRenderer( } // 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 +169,34 @@ internal class ComposeRenderer( } } + /** + * Clear states of removed screens from given [stateHolder]. + * @param stateHolder - SaveableStateHolder that contains screen states + * @param clearAll - forces to remove all screen states that renderer holds (removed and "displayed") + */ + private fun afterScreenContentOnDispose(clearAll: Boolean = false) { + if (clearAll) { + state?.getChildScreens()?.afterScreenContentOnDispose() + } + // 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.afterScreenContentOnDispose() + } + private fun Iterable.clearStates(stateHolder: SaveableStateHolder) = forEach { screen -> screen.clearState(stateHolder) } + private fun Iterable.afterScreenContentOnDispose() = forEach { screen -> + screen.afterScreenContentOnDispose() + } + 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 +208,16 @@ internal class ComposeRenderer( ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true) } + // need for correct handling lifecycle + private fun Screen.afterScreenContentOnDispose() { +// Log.d("LifecycleDebug", "afterScreenContentOnDispose $screenKey") + dependenciesSortedByRemovePriority() + .filterIsInstance() + .forEach { it.onPreDispose() } + // send afterScreenContentOnDispose to nested screens + ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.afterScreenContentOnDispose(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/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 5ca15da..d92f3d7 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 @@ -36,22 +36,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) @@ -118,21 +126,34 @@ class ModoScreenAndroidAdapter private constructor( ) { val context: Context = LocalContext.current val parentLifecycleOwner = LocalLifecycleOwner.current - LifecycleDisposableEffect(context, parentLifecycleOwner) - @Suppress("SpreadOperator") - CompositionLocalProvider(*getProviders()) { - content() + LifecycleDisposableEffect(context, parentLifecycleOwner) { + @Suppress("SpreadOperator") + CompositionLocalProvider(*getProviders()) { + content() + } } } - @Suppress("UnusedParameter") - fun onDispose(screen: Screen) { - viewModelStore.clear() + /** + * 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.") disposeEvents.forEach { event -> lifecycle.safeHandleLifecycleEvent(event) } } + @Suppress("UnusedParameter") + private fun onDispose() { +// Log.d("LifecycleDebug", "${screen.screenKey} ModoScreenAndroidAdapter.onDispose. Clear ViewModelStore.") + viewModelStore.clear() + } + + override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" + private fun performSave(outState: Bundle) { controller.performSave(outState) } @@ -188,6 +209,7 @@ class ModoScreenAndroidAdapter private constructor( private fun LifecycleDisposableEffect( context: Context, parentLifecycleOwner: LifecycleOwner, + content: @Composable () -> Unit ) { val activity = remember(context) { context.getActivity() @@ -197,6 +219,8 @@ class ModoScreenAndroidAdapter private constructor( onCreate(savedState) // do this in the UI thread to force it to be called before anything else } + content() + DisposableEffect(this) { val unregisterLifecycle = registerParentLifecycleListener(parentLifecycleOwner) { LifecycleEventObserver { owner, event -> @@ -225,6 +249,7 @@ class ModoScreenAndroidAdapter private constructor( emitOnStartEvents() onDispose { +// Log.d("LifecycleDebug", "ModoScreenAndroidAdapter registerParentLifecycleListener onDispose ${screen.screenKey}") unregisterLifecycle() // when the screen goes to stack, perform save performSave(savedState) @@ -275,7 +300,7 @@ class ModoScreenAndroidAdapter private constructor( ScreenModelStore.getOrPutDependency( screen = screen, name = "AndroidScreenLifecycleOwner", - onDispose = { it.onDispose(screen) } - ) { ModoScreenAndroidAdapter() } + onDispose = { it.onDispose() }, + ) { ModoScreenAndroidAdapter(screen) } } } \ 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..45397de 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,15 +11,14 @@ 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.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 -val displayingScreens = mutableStateMapOf() +val displayingScreensBeforeScreenContent = mutableStateMapOf() +val displayingScreensAfterScreenContent = mutableStateMapOf() typealias ScreenTransitionContent = @Composable AnimatedVisibilityScope.(Screen) -> Unit @@ -45,10 +44,4 @@ fun ComposeRendererScope<*>.ScreenTransition( contentKey = { it.screenKey }, content = content ) - if (transition.currentState == transition.targetState) { - val channel = LocalTransitionCompleteChannel.current - LaunchedEffect(Unit) { - channel.trySend(Unit) - } - } } \ 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..835ca9b --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt @@ -0,0 +1,11 @@ +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 { + fun onPreDispose() +} \ 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..4f7bb9a --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt @@ -0,0 +1,6 @@ +package com.github.terrakok.modo.model + +import com.github.terrakok.modo.Screen + +fun Screen.dependenciesSortedByRemovePriority(): Sequence = + ScreenModelStore.screenDependenciesSortedByRemovePriority(this) \ 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..82007d9 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. @@ -97,10 +100,16 @@ 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( @@ -128,17 +137,22 @@ 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 } + @PublishedApi internal fun assertGetOrPutScreenModelsCorrect(screen: Screen, valueInMap: Any?) { assertGetOrPutCorrect( @@ -181,8 +195,8 @@ object ScreenModelStore { } } - private fun Map.screenDependencies(screen: Screen): Sequence> = - asSequence().filter { it.key.startsWith(screen.screenKey.value) } + internal fun screenDependenciesInternal(screen: Screen): Sequence> = + dependencies.asSequence().filter { it.key.startsWith(screen.screenKey.value) } private fun Map.onEach(screen: Screen, block: (String) -> Unit) = asSequence() 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..4f80111 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 @@ -148,11 +147,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 -> diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c0141dc..3df8dc1 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { 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/java/com/github/terrakok/modo/sample/Animations.kt b/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt index c4df1fe..7f713aa 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,7 @@ 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 @@ -43,8 +44,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..074e7d3 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,10 @@ 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 @@ -40,7 +35,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( @@ -52,14 +46,14 @@ class MainScreen( @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, 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/ScreenEffectsSampleScreen.kt index a69bda4..f6f154e 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/ScreenEffectsSampleScreen.kt @@ -5,6 +5,7 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,6 +14,8 @@ 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 @@ -25,6 +28,9 @@ 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 @@ -34,7 +40,11 @@ class ScreenEffectsSampleScreen( 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 +52,53 @@ 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.") } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + // Otherwise you will lose ON_PAUSE, ON_STOP, ON_DESTROY events, because of peculiarities of coroutines - + // it removes lifecycle observer before handling effects + // + 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." } + } + } + } +// DisposableEffect(this) { +// val observer = LifecycleEventObserver { _, event -> +//// lifecycleEventsHistory += event +// logcat(TAG) { "DisposableEffect: event $event. Counter: $counter." } +// } +// lifecycleOwner.lifecycle.addObserver(observer) +// onDispose { +// lifecycleOwner.lifecycle.removeObserver(observer) +// } +// } +// DisposableEffect(this) { +// logcat(TAG) { "Analytics: screen created. Counter: $counter." } +// onDispose { +// logcat(TAG) { "Analytics: screen destroyed. Counter: $counter." } +// } +// } LifecycleScreenEffect { LifecycleEventObserver { _, event -> lifecycleEventsHistory += event - logcat { "Lifecycle event $event. Counter: $counter." } + logcat(TAG) { "LifecycleScreenEffect: event $event. Counter: $counter." } } } DisposableScreenEffect { - logcat { "Analytics: screen created. Counter: $counter." } + logcat(TAG) { "Analytics: screen created. Counter: $counter." } onDispose { - logcat { "Analytics: screen destroyed. Counter: $counter." } + logcat(TAG) { "Analytics: screen destroyed. Counter: $counter." } } } val navigation = LocalStackNavigation.current 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/custom/InnerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/InnerScreen.kt index 680315e..fb393fa 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 @@ -22,6 +22,7 @@ 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 @@ -31,18 +32,13 @@ 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/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) } } From 6e112966e7dc2332bfb7b1e062ea91a9d5d3f067 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 10 Jul 2024 17:27:40 +0200 Subject: [PATCH 02/36] Fixed detekt and bumped version --- config/detekt/detekt.yml | 2 +- gradle/libs.versions.toml | 2 +- .../com/github/terrakok/modo/ComposeRender.kt | 5 +-- sample/build.gradle.kts | 1 + .../modo/sample/screens/MainScreen.kt | 2 - .../screens/ScreenEffectsSampleScreen.kt | 40 +++++++------------ 6 files changed, 19 insertions(+), 33 deletions(-) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index eecca4e..726aa77 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -882,7 +882,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 086734d..bbd2622 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] leakcanaryAndroid = "2.14" -modo = "0.9.0" +modo = "0.10.0-alpha1" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" 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 b909437..0d2fe42 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 @@ -1,6 +1,5 @@ package com.github.terrakok.modo -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -64,7 +63,7 @@ private inline fun Screen.BeforeScreenContent() { displayingScreensBeforeScreenContent[this@BeforeScreenContent] = Unit onDispose { displayingScreensBeforeScreenContent -= this@BeforeScreenContent - Log.d("LifecycleDebug", "BeforeScreenContent $screenKey onDispose") +// Log.d("LifecycleDebug", "BeforeScreenContent $screenKey onDispose") onDisposed.invoke() } } @@ -77,7 +76,7 @@ private inline fun Screen.AfterScreenContent() { displayingScreensAfterScreenContent[this@AfterScreenContent] = Unit onDispose { displayingScreensAfterScreenContent -= this@AfterScreenContent - Log.d("LifecycleDebug", "AfterScreenContent $screenKey onDispose") +// Log.d("LifecycleDebug", "AfterScreenContent $screenKey onDispose") onPreDispose() } } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 3df8dc1..a71087a 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -34,6 +34,7 @@ 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) 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 074e7d3..ffc7f05 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,7 +7,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity -import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey @@ -43,7 +42,6 @@ class MainScreen( override val screenKey: ScreenKey = generateScreenKey() ) : Screen { - @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { // OnScreenRemoved { 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/ScreenEffectsSampleScreen.kt index f6f154e..d9d193a 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/ScreenEffectsSampleScreen.kt @@ -5,6 +5,7 @@ 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 @@ -20,7 +21,6 @@ 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.base.SampleScreenContent @@ -62,9 +62,9 @@ class ScreenEffectsSampleScreen( } val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(Unit) { - // Otherwise you will lose ON_PAUSE, ON_STOP, ON_DESTROY events, because of peculiarities of coroutines - - // it removes lifecycle observer before handling effects - // + // 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" } @@ -73,34 +73,22 @@ class ScreenEffectsSampleScreen( } } } -// DisposableEffect(this) { -// val observer = LifecycleEventObserver { _, event -> -//// lifecycleEventsHistory += event -// logcat(TAG) { "DisposableEffect: event $event. Counter: $counter." } -// } -// lifecycleOwner.lifecycle.addObserver(observer) -// onDispose { -// lifecycleOwner.lifecycle.removeObserver(observer) -// } -// } -// DisposableEffect(this) { -// logcat(TAG) { "Analytics: screen created. Counter: $counter." } -// onDispose { -// logcat(TAG) { "Analytics: screen destroyed. Counter: $counter." } -// } -// } + DisposableEffect(this) { + val observer = LifecycleEventObserver { _, event -> + logcat(TAG) { "DisposableEffect: event $event. Counter: $counter." } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + logcat(TAG) { "DisposableEffect: on dispose" } + lifecycleOwner.lifecycle.removeObserver(observer) + } + } LifecycleScreenEffect { LifecycleEventObserver { _, event -> lifecycleEventsHistory += event logcat(TAG) { "LifecycleScreenEffect: event $event. Counter: $counter." } } } - DisposableScreenEffect { - logcat(TAG) { "Analytics: screen created. Counter: $counter." } - onDispose { - logcat(TAG) { "Analytics: screen destroyed. Counter: $counter." } - } - } val navigation = LocalStackNavigation.current SampleScreenContent( screenIndex = screenIndex, From f3cddfb39c7855bd564d079626a8e68f095dd7f6 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 10 Jul 2024 17:32:25 +0200 Subject: [PATCH 03/36] Fix detekt --- .../main/java/com/github/terrakok/modo/ComposeRender.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 0d2fe42..0c03f27 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 @@ -31,6 +31,7 @@ val defaultRendererContent: (@Composable ComposeRendererScope<*>.(screenModifier val LocalSaveableStateHolder = staticCompositionLocalOf { null } private val LocalBeforeScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { error("No LocalBeforeScreenContentOnDispose provided!") } + private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { error("No LocalAfterScreenContentOnDispose provided!") } /** @@ -129,13 +130,17 @@ internal class ComposeRenderer( val stateHolder: SaveableStateHolder = LocalSaveableStateHolder.currentOrThrow val beforeScreenContentOnDispose = remember { - { clearScreens(stateHolder) } + { + 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 { - { afterScreenContentOnDispose() } + { + afterScreenContentOnDispose() + } } CompositionLocalProvider( From f8b85252816a74b6008eb36da6ba33f6d472e26b Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 10 Jul 2024 17:42:32 +0200 Subject: [PATCH 04/36] Fix detekt --- .../main/java/com/github/terrakok/modo/ComposeRender.kt | 8 ++++++-- .../com/github/terrakok/modo/list/ListNavigationAction.kt | 3 +-- .../modo/sample/screens/containers/custom/InnerScreen.kt | 4 ---- 3 files changed, 7 insertions(+), 8 deletions(-) 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 0c03f27..7266aa2 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 @@ -30,9 +30,13 @@ val defaultRendererContent: (@Composable ComposeRendererScope<*>.(screenModifier val LocalSaveableStateHolder = staticCompositionLocalOf { null } -private val LocalBeforeScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { error("No LocalBeforeScreenContentOnDispose provided!") } +private val LocalBeforeScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { + error("No LocalBeforeScreenContentOnDispose provided!") +} -private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { error("No LocalAfterScreenContentOnDispose provided!") } +private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> { + error("No LocalAfterScreenContentOnDispose provided!") +} /** * Provides integration of [Screen] to Modo's navigation system: 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/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 fb393fa..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,19 +13,15 @@ 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( From 5f88d079837d4f1d8c76e0746f9c3f33ffe08e96 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 10 Jul 2024 17:55:29 +0200 Subject: [PATCH 05/36] Updated pr checks base branches --- .github/workflows/pr_checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index a0e7c4a..b75dfad 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - "dev" + - "v\d+\.\d+\.\d+" jobs: static-analysis-check: name: Static code analysis From e79762c80ab0866fcfa8c5af1647f9d9cdcc9258 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 10 Jul 2024 17:58:45 +0200 Subject: [PATCH 06/36] Updated pr checks base branches --- .github/workflows/pr_checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index b75dfad..3539265 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -7,7 +7,7 @@ on: pull_request: branches: - "dev" - - "v\d+\.\d+\.\d+" + - "releases/**" jobs: static-analysis-check: name: Static code analysis From 1f01046bdb489d3660fb6848f58e5fd974ad0682 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Tue, 23 Jul 2024 15:30:40 +0200 Subject: [PATCH 07/36] Fixed order of start events, now parent start and resume happens before child. Also, it fixed back handling after activity recreation. --- TestInstructions.md | 5 ++++- gradle/libs.versions.toml | 2 +- .../modo/android/ModoScreenAndroidAdapter.kt | 7 ++++-- .../github/terrakok/modo/stack/StackScreen.kt | 22 ++++++++++++------- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/TestInstructions.md b/TestInstructions.md index 22ae5c8..13a50f5 100644 --- a/TestInstructions.md +++ b/TestInstructions.md @@ -11,4 +11,7 @@ certain cases: * 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 \ No newline at end of file +* 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 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bbd2622..5644681 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] leakcanaryAndroid = "2.14" -modo = "0.10.0-alpha1" +modo = "0.10.0-alpha2" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" 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 d92f3d7..4168fc8 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 @@ -219,6 +219,11 @@ class ModoScreenAndroidAdapter private constructor( onCreate(savedState) // do this in the UI thread to force it to be called before anything else } + DisposableEffect(this) { + emitOnStartEvents() + onDispose { } + } + content() DisposableEffect(this) { @@ -246,8 +251,6 @@ class ModoScreenAndroidAdapter private constructor( } } - emitOnStartEvents() - onDispose { // Log.d("LifecycleDebug", "ModoScreenAndroidAdapter registerParentLifecycleListener onDispose ${screen.screenKey}") unregisterLifecycle() 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 4f80111..e14192e 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 @@ -72,6 +72,8 @@ abstract class StackScreen( dialogModifier: Modifier = Modifier, content: RendererContent = defaultRendererContent ) { + StackBackHandler() + val screensToRender: ScreensToRender by rememberScreensToRender() screensToRender.screen?.let { screen -> Content(screen, modifier, content) @@ -131,6 +133,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( @@ -174,14 +188,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) } From 4b95d65c56b2fe9f3d9593f7b5a2cc20ef4b8453 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 21 Aug 2024 15:43:36 +0200 Subject: [PATCH 08/36] Added emitting ON_RESUME and ON_PAUSE for transition when it finishes and starts along with support of manual resuming and pausing lifecycle of the screen --- .../com/github/terrakok/modo/ComposeRender.kt | 53 ++++-- .../modo/android/ModoScreenAndroidAdapter.kt | 164 ++++++++++-------- .../modo/animation/ScreenTransitions.kt | 39 ++++- .../modo/lifecycle/LifecycleDependency.kt | 17 ++ .../github/terrakok/modo/model/ScreenExt.kt | 6 +- .../terrakok/modo/model/ScreenModelStore.kt | 83 +++++---- .../github/terrakok/modo/sample/Animations.kt | 3 +- .../screens/base/ButtonsScreenContent.kt | 11 +- .../sample/screens/containers/SampleStack.kt | 10 +- 9 files changed, 241 insertions(+), 145 deletions(-) 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 7266aa2..7e3345a 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 @@ -13,7 +13,12 @@ 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.ScreenTransition import com.github.terrakok.modo.animation.displayingScreensAfterScreenContent import com.github.terrakok.modo.animation.displayingScreensBeforeScreenContent import com.github.terrakok.modo.lifecycle.LifecycleDependency @@ -44,11 +49,22 @@ private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Un * 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 */ @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 { + ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(manualResumePause) { BeforeScreenContent() Content(modifier) AfterScreenContent() @@ -143,7 +159,7 @@ internal class ComposeRenderer( // to let Screen.Content to handle ON_DISPOSE by using functions like DisposableEffect val afterScreenContentOnDispose = remember { { - afterScreenContentOnDispose() + onPreDispose() } } @@ -163,6 +179,10 @@ 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) } @@ -178,26 +198,21 @@ internal class ComposeRenderer( } /** - * Clear states of removed screens from given [stateHolder]. - * @param stateHolder - SaveableStateHolder that contains screen states - * @param clearAll - forces to remove all screen states that renderer holds (removed and "displayed") + * Called onPreDispose for removed screens + * @param clearAll - forces to call onPreDispose on all children screen states that renderer holds (removed and "displayed") */ - private fun afterScreenContentOnDispose(clearAll: Boolean = false) { + private fun onPreDispose(clearAll: Boolean = false) { + fun Iterable.onPreDispose() = forEach { screen -> + screen.onPreDispose() + } + if (clearAll) { - state?.getChildScreens()?.afterScreenContentOnDispose() + 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.afterScreenContentOnDispose() - } - - private fun Iterable.clearStates(stateHolder: SaveableStateHolder) = forEach { screen -> - screen.clearState(stateHolder) - } - - private fun Iterable.afterScreenContentOnDispose() = forEach { screen -> - screen.afterScreenContentOnDispose() + safeToRemove.onPreDispose() } private fun Screen.clearState(stateHolder: SaveableStateHolder) { @@ -217,13 +232,13 @@ internal class ComposeRenderer( } // need for correct handling lifecycle - private fun Screen.afterScreenContentOnDispose() { + 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<*>)?.afterScreenContentOnDispose(clearAll = true) + ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.onPreDispose(clearAll = true) } private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List { 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 4168fc8..1810a0d 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 @@ -99,34 +105,14 @@ 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) - } - } - - private fun emitOnStartEvents() { - startEvents.forEach { - lifecycle.safeHandleLifecycleEvent(it) - } - } - - private fun emitOnStopEvents() { - stopEvents.forEach { - lifecycle.safeHandleLifecycleEvent(it) - } - } - @Composable fun ProvideAndroidIntegration( - content: @Composable () -> Unit + manualResumePause: Boolean = false, + content: @Composable () -> Unit, ) { val context: Context = LocalContext.current val parentLifecycleOwner = LocalLifecycleOwner.current - LifecycleDisposableEffect(context, parentLifecycleOwner) { + LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) { @Suppress("SpreadOperator") CompositionLocalProvider(*getProviders()) { content() @@ -141,18 +127,31 @@ class ModoScreenAndroidAdapter private constructor( */ override fun onPreDispose() { // Log.d("LifecycleDebug", "${screen.screenKey} ModoScreenAndroidAdapter.onPreDispose, emit ON_DESTROY event.") - disposeEvents.forEach { event -> - lifecycle.safeHandleLifecycleEvent(event) - } + safeHandleLifecycleEvent(ON_DESTROY) + } + + override fun onPause() { + safeHandleLifecycleEvent(ON_PAUSE) + } + + override fun onResume() { + safeHandleLifecycleEvent(ON_RESUME) } + override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" + @Suppress("UnusedParameter") private fun onDispose() { // Log.d("LifecycleDebug", "${screen.screenKey} ModoScreenAndroidAdapter.onDispose. Clear ViewModelStore.") viewModelStore.clear() } - override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" + private fun onCreate(savedState: Bundle?) { + check(!isCreated) { "onCreate already called" } + isCreated = true + controller.performRestore(savedState) + safeHandleLifecycleEvent(ON_CREATE) + } private fun performSave(outState: Bundle) { controller.performSave(outState) @@ -209,6 +208,7 @@ class ModoScreenAndroidAdapter private constructor( private fun LifecycleDisposableEffect( context: Context, parentLifecycleOwner: LifecycleOwner, + manualResumePause: Boolean, content: @Composable () -> Unit ) { val activity = remember(context) { @@ -220,7 +220,10 @@ class ModoScreenAndroidAdapter private constructor( } DisposableEffect(this) { - emitOnStartEvents() + safeHandleLifecycleEvent(ON_START) + if (!manualResumePause) { + safeHandleLifecycleEvent(ON_RESUME) + } onDispose { } } @@ -228,26 +231,20 @@ class ModoScreenAndroidAdapter private constructor( 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) } } @@ -257,43 +254,34 @@ class ModoScreenAndroidAdapter private constructor( // 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 ) /** @@ -302,8 +290,38 @@ class ModoScreenAndroidAdapter private constructor( fun get(screen: Screen): ModoScreenAndroidAdapter = ScreenModelStore.getOrPutDependency( screen = screen, - name = "AndroidScreenLifecycleOwner", + name = LifecycleDependency.KEY, onDispose = { it.onDispose() }, ) { ModoScreenAndroidAdapter(screen) } + + 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 + } + + private 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/ScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt index 45397de..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,11 +11,13 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable +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.SaveableContent import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.model.lifecycleDependency val displayingScreensBeforeScreenContent = mutableStateMapOf() val displayingScreensAfterScreenContent = mutableStateMapOf() @@ -24,6 +26,17 @@ typealias ScreenTransitionContent = @Composable AnimatedVisibilityScope.(Screen) /** * 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 @@ -35,13 +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 - ) + ) { 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/lifecycle/LifecycleDependency.kt b/modo-compose/src/main/java/com/github/terrakok/modo/lifecycle/LifecycleDependency.kt index 835ca9b..6cc9c6f 100644 --- 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 @@ -7,5 +7,22 @@ import com.github.terrakok.modo.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/model/ScreenExt.kt b/modo-compose/src/main/java/com/github/terrakok/modo/model/ScreenExt.kt index 4f7bb9a..27eb9bb 100644 --- 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 @@ -1,6 +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) \ No newline at end of file + 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 82007d9..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 @@ -56,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( @@ -92,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 @@ -112,16 +98,18 @@ object ScreenModelStore { .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 ""}" @@ -153,6 +141,38 @@ object ScreenModelStore { 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( @@ -195,9 +215,6 @@ object ScreenModelStore { } } - internal fun screenDependenciesInternal(screen: Screen): Sequence> = - dependencies.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/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt b/sample/src/main/java/com/github/terrakok/modo/sample/Animations.kt index 7f713aa..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,5 @@ 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 @@ -19,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 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 c4c3d4d..7a62ad4 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 @@ -28,7 +28,6 @@ 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 @@ -120,11 +119,11 @@ fun Screen.LogLifecycle() { lifecycleOwner.lifecycle.removeObserver(observer) } } - LifecycleScreenEffect { - LifecycleEventObserver { source, event -> - logcat(tag = "LifecycleDebug") { "$screenKey LifecycleScreenEffect $event" } - } - } +// LifecycleScreenEffect { +// LifecycleEventObserver { source, event -> +// logcat(tag = "LifecycleDebug") { "$screenKey LifecycleScreenEffect $event" } +// } +// } } @Composable 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..90c67cd 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,11 +55,7 @@ open class SampleStack( @OptIn(ExperimentalModoApi::class) @Composable override fun Content(modifier: Modifier) { - LifecycleScreenEffect { - LifecycleEventObserver { _, event -> - logcat(tag = "SampleStack") { "$screenKey lifecycle event $event" } - } - } + LogLifecycle() TopScreenContent(modifier) { modifier -> SlideTransition(modifier) } From 7d7d97538612e19f2f1f360df2f38f58dace2c85 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 22 Aug 2024 12:03:12 +0200 Subject: [PATCH 09/36] Added sample for keyboard show after animation is finished --- .../modo/sample/screens/MainScreen.kt | 10 ++- .../lifecycle/KeyboardWithLifecycleScreen.kt | 76 +++++++++++++++++++ .../LifecycleSampleScreen.kt} | 8 +- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/KeyboardWithLifecycleScreen.kt rename sample/src/main/java/com/github/terrakok/modo/sample/screens/{ScreenEffectsSampleScreen.kt => lifecycle/LifecycleSampleScreen.kt} (93%) 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 ffc7f05..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 @@ -26,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 @@ -141,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") { @@ -158,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/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 93% 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 d9d193a..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,4 +1,4 @@ -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 @@ -23,6 +23,10 @@ import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey 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 @@ -35,7 +39,7 @@ import kotlinx.parcelize.Parcelize import logcat.logcat @Parcelize -class ScreenEffectsSampleScreen( +class LifecycleSampleScreen( private val screenIndex: Int, override val screenKey: ScreenKey = generateScreenKey() ) : Screen { From 47fd462a3b960657881446848866ecff10b6aa8a Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 22 Aug 2024 13:19:39 +0200 Subject: [PATCH 10/36] Added jvmStatic --- .../github/terrakok/modo/android/ModoScreenAndroidAdapter.kt | 3 +++ 1 file changed, 3 insertions(+) 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 1810a0d..b08e95b 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 @@ -287,6 +287,7 @@ class ModoScreenAndroidAdapter private constructor( /** * 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, @@ -294,6 +295,7 @@ class ModoScreenAndroidAdapter private constructor( onDispose = { it.onDispose() }, ) { ModoScreenAndroidAdapter(screen) } + @JvmStatic fun needPropagateLifecycleEventFromParent( event: Lifecycle.Event, isActivityFinishing: Boolean?, @@ -317,6 +319,7 @@ class ModoScreenAndroidAdapter private constructor( event !in moveLifecycleStateUpEvents } + @JvmStatic private 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. From dd5b09fc366c429c09ad7ab332c1d6aa77277e9a Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 22 Aug 2024 13:35:05 +0200 Subject: [PATCH 11/36] Published alpha --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5644681..77ffb26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] leakcanaryAndroid = "2.14" -modo = "0.10.0-alpha2" +modo = "0.10.0-alpha3" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" From ac0d216ce9330e105ab1d35f2458cf6d2c1b64aa Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Tue, 3 Sep 2024 12:44:08 +0200 Subject: [PATCH 12/36] First version of workshop app --- config/detekt/detekt.yml | 1 + gradle/libs.versions.toml | 3 + .../com/github/terrakok/modo/ModoModels.kt | 5 + .../modo/animation/SlideTransition.kt | 69 ++++++ .../modo/multiscreen/MultiScreenState.kt | 11 +- .../github/terrakok/modo/stack/StackScreen.kt | 2 +- settings.gradle.kts | 2 + workshop-app/build.gradle.kts | 49 +++++ workshop-app/src/main/AndroidManifest.xml | 26 +++ .../ikarenkov/workshop/WorkShopStackScreen.kt | 10 + .../ikarenkov/workshop/WorkshopActivity.kt | 22 ++ .../workshop/WorkshopActivityFinal.kt | 38 ++++ .../github/ikarenkov/workshop/WorkshopApp.kt | 31 +++ .../ikarenkov/workshop/WorkshopConfig.kt | 24 +++ .../ikarenkov/workshop/core/StateFlowEtx.kt | 13 ++ .../workshop/data/ClimberProfileRepository.kt | 17 ++ .../ikarenkov/workshop/di/RootModule.kt | 19 ++ .../workshop/domain/ClimberProfile.kt | 22 ++ .../ikarenkov/workshop/domain/ClimbingType.kt | 5 + .../workshop/domain/FrenchScaleGrade.kt | 49 +++++ .../workshop/screens/SampleScreen.kt | 9 + .../workshop/screens/SampleScreenContent.kt | 41 ++++ .../workshop/screens/SampleScreenFinal.kt | 35 +++ .../screens/TrainingRecommendationsScreen.kt | 107 ++++++++++ .../workshop/screens/WorkshopTheme.kt | 18 ++ .../workshop/screens/auth/AuthCodeScreen.kt | 190 +++++++++++++++++ .../screens/auth/AuthContainerScreen.kt | 21 ++ .../workshop/screens/auth/EmailScreen.kt | 79 +++++++ .../workshop/screens/auth/EmailScreenFinal.kt | 28 +++ .../climbing_level/ClimbingLevelScreen.kt | 196 +++++++++++++++++ .../climbing_level/ClimbingLevelViewModel.kt | 59 ++++++ .../workshop/screens/main/MainTabContent.kt | 91 ++++++++ .../workshop/screens/main/MainTabScreen.kt | 10 + .../screens/main/MainTabScreenFinal.kt | 45 ++++ .../ClimberPersonalInfoScreen.kt | 174 +++++++++++++++ .../ClimberPersonalInfoViewModel.kt | 32 +++ .../screens/profile/EnhancedProfileScreen.kt | 200 ++++++++++++++++++ .../profile/EnhancedProfileViewModel.kt | 46 ++++ .../screens/profile/ProfileContent.kt | 91 ++++++++ .../workshop/screens/profile/ProfileScreen.kt | 26 +++ .../profile_setup/ProfileSetupFlowContent.kt | 143 +++++++++++++ .../profile_setup/ProfileSetupFlowScreen.kt | 47 ++++ .../ProfileSetupFlowScreenFinal.kt | 72 +++++++ .../ProfileSetupFlowViewModel.kt | 77 +++++++ .../ProfileSetupFlowViewModelFinal.kt | 111 ++++++++++ .../screens/profile_setup/SetupStepScreen.kt | 7 + .../github/ikarenkov/workshop/ui/CommonUi.kt | 94 ++++++++ .../workshop/ui/progress/ProgressBar.kt | 161 ++++++++++++++ .../workshop/ui/progress/ProgressBarColors.kt | 36 ++++ .../workshop/ui/progress/ProgressBarSizes.kt | 33 +++ .../workshop/ui/progress/ProgressBarType.kt | 6 + .../src/main/res/values-v23/themes.xml | 19 ++ workshop-app/src/main/res/values/themes.xml | 13 ++ 53 files changed, 2731 insertions(+), 4 deletions(-) create mode 100644 modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt create mode 100644 workshop-app/build.gradle.kts create mode 100644 workshop-app/src/main/AndroidManifest.xml create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkShopStackScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivityFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopConfig.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/core/StateFlowEtx.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/data/ClimberProfileRepository.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimberProfile.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/ClimbingType.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/domain/FrenchScaleGrade.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/WorkshopTheme.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthCodeScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/AuthContainerScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/climbing_level/ClimbingLevelViewModel.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreenFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoViewModel.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreenFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModel.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/SetupStepScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/CommonUi.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBar.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarColors.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarSizes.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/progress/ProgressBarType.kt create mode 100644 workshop-app/src/main/res/values-v23/themes.xml create mode 100644 workshop-app/src/main/res/values/themes.xml diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 726aa77..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: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77ffb26..8897911 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ kotlin = "1.9.23" kotlinCompilerExtension = "1.5.12" minSdk = "21" compileSdk = "34" +koin = "4.0.0-RC1" [libraries] androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" } @@ -54,6 +55,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/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index 2bc405c..13e264d 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,8 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.Flow /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -38,6 +40,9 @@ interface NavigationContainer> NavigationContainer.navigationStateFlow(): Flow = + snapshotFlow { navigationState } + interface NavigationRenderer { fun render(state: State) } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt new file mode 100644 index 0000000..ff4a2f7 --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt @@ -0,0 +1,69 @@ +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.scaleIn +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(), + animationSpec: FiniteAnimationSpec = tween(durationMillis = 1000), + content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } +) { + ScreenTransition( + modifier = modifier, + screenModifier = screenModifier, + transitionSpec = { + val transitionType: StackTransitionType = calculateStackTransitionType() + when { + transitionType == StackTransitionType.Replace -> { + scaleIn(initialScale = 2f) + fadeIn() togetherWith fadeOut() + } + oldState?.stack?.last() is DialogScreen || newState?.stack?.last() is DialogScreen -> { + fadeIn() togetherWith fadeOut() + } + else -> { + when (transitionType) { + StackTransitionType.Push -> slideIntoContainer(pushDirection, animationSpec = animationSpec) togetherWith + slideOutOfContainer(pushDirection, animationSpec = animationSpec) + else -> slideIntoContainer(popDirection, animationSpec = animationSpec) togetherWith + slideOutOfContainer(popDirection, animationSpec = animationSpec) + } + } + } + }, + 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/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 e14192e..027032f 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 @@ -227,7 +227,7 @@ data class DialogPlaceHolder( override fun Content(modifier: Modifier) { Box( // ignore modifier, because it is just invisible placeholder - Modifier.fillMaxSize() + 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..b1af607 --- /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("com.github.zj565061763:compose-wheel-picker:1.0.0-beta05") + 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..017a216 --- /dev/null +++ b/workshop-app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ 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..f207ae6 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt @@ -0,0 +1,22 @@ +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 + 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..12841d2 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/core/StateFlowEtx.kt @@ -0,0 +1,13 @@ +package io.github.ikarenkov.workshop.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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)) \ 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..20951fb --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt @@ -0,0 +1,19 @@ +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_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(it.get(), get()) } + viewModel { ClimberPersonalInfoViewModel(get()) } + // TODO: Workshop 5.1.2 - di define ProfileSetupViewModel +// viewModel { ProfileSetupViewModel(it.get(), 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..2918150 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt @@ -0,0 +1,9 @@ +package io.github.ikarenkov.workshop.screens + +// 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 QuickStartScreen class Parcelable +// TODO: Workshop 1.2.4 - implement Content function with QuickStartScreenContent composable +// TODO: Workshop 2.1 - get stack navigation using LocalStackNavigation.current +// TODO: Workshop 2.2 - navigate to next screen using forward function +// TODO: Workshop 3.1.4 - navigate to MainTabScreen from QuickStartScreen \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt new file mode 100644 index 0000000..543991f --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt @@ -0,0 +1,41 @@ +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 + +@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..bb3ef33 --- /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 QuickStartScreen 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 QuickStartScreenContent 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/TrainingRecommendationsScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsScreen.kt new file mode 100644 index 0000000..1a5f07d --- /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 +private 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..4da3bf9 --- /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 +private 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..338527d --- /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 +private 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/MainTabContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt new file mode 100644 index 0000000..b1be9f3 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt @@ -0,0 +1,91 @@ +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 + +@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 = { + // TODO: Workshop 3.3 - navigate between tabs + onTabClick(pos) + }, + ) { + val contentColor = LocalContentColor.current + // TODO: Workshop 3.4 - use navigation state to define UI + 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/MainTabScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt new file mode 100644 index 0000000..3694051 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt @@ -0,0 +1,10 @@ +package io.github.ikarenkov.workshop.screens.main + +// 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 QuickStartScreen +// 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 \ 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..9f0840e --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreenFinal.kt @@ -0,0 +1,45 @@ +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.multiscreen.MultiScreen +import com.github.terrakok.modo.multiscreen.MultiScreenNavModel +import com.github.terrakok.modo.multiscreen.selectContainer +import io.github.ikarenkov.workshop.screens.SampleScreenFinal +import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreen +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), + EnhancedProfileScreen(), + 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 + selectContainer(pos) + } + ) { paddingValues -> + // Workshop 3.2 - display selected screen + SelectedScreen( + Modifier + .padding(paddingValues) + .fillMaxSize() + ) + + } + } +} \ 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..8a84139 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreen.kt @@ -0,0 +1,174 @@ +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.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 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() + ClimberProfileSetupScreenContent( + modifier = modifier, + 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 +private fun ClimberProfileSetupScreenContent( + dateOfBirth: Date?, + setDateOfBirth: (Date) -> Unit, + height: String, + setHeight: (String) -> Unit, + weight: String, + setWeight: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + 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(), + ) + InputNumRow("Height", height, setHeight, "sm", Modifier.fillMaxWidth()) + InputNumRow("Weight", weight, setWeight, "kg", 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/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..9e45339 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt @@ -0,0 +1,200 @@ +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.NavigationState +import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.Screen +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.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.ClimberPersonalInfoScreen +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 EnhancedProfileScreen( + 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(TrainingRecommendationsScreen()) + }, + 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) + } + } + } + } + } +} + +@Composable +private 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() + } +} + +@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 + ) + +} + +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 + } + ) + +} + +@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/EnhancedProfileViewModel.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt new file mode 100644 index 0000000..2477860 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModel.kt @@ -0,0 +1,46 @@ +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 +import kotlinx.coroutines.launch + +class EnhancedProfileViewModel( + private val enhancedProfileScreen: EnhancedProfileScreen, + private val climberProfileRepository: ClimberProfileRepository +) : ViewModel() { + + val state: StateFlow = climberProfileRepository.climberProfile + .mapStateFlow(viewModelScope) { + it.toUiState() + } + + init { + viewModelScope.launch { + climberProfileRepository.climberProfile.collect { profile -> + enhancedProfileScreen.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 + ) +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt new file mode 100644 index 0000000..ea68f9a --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt @@ -0,0 +1,91 @@ +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 + +@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/ProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..823b0c4 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt @@ -0,0 +1,26 @@ +package io.github.ikarenkov.workshop.screens.profile + +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 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 + } + ) + } +} \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt new file mode 100644 index 0000000..1f39600 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt @@ -0,0 +1,143 @@ +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.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.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 io.github.ikarenkov.workshop.ui.progress.ProgressBar + +@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 + ) { + content(Modifier.weight(1f)) + 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 +private 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") + } + } + } +} \ 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..599d599 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt @@ -0,0 +1,47 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +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 + +// TODO: Workshop 4.1.1 - create ProfileSetupFlowScreen inheriting from StackScreen +@Parcelize +class ProfileSetupFlowScreen( +// TODO: Workshop 4.1.2 - 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 4.4 - use navigation state to retrieve current step + val state = ProfileSetupContainerUiState( + title = "Step #1", + currentStep = 1, + stepsCount = 4, + continueEnabled = true + ) + ProfileSetupFlowContainerContent( + modifier = modifier, + state = state, + onContinueClick = { + // TODO: Workshop 4.3.1 - navigation based on selected screen + // 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.3 - display content + // TODO: Workshop 4.5 - custom animation + } + } + +} \ 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..cec1436 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreenFinal.kt @@ -0,0 +1,72 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.DialogScreen +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.ClimberPersonalInfoScreen +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(ClimberPersonalInfoScreen()) +) : 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 + .fillMaxHeight(0.8f) + .clip(RoundedCornerShape(16.dp)), + 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..fdc1833 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModel.kt @@ -0,0 +1,77 @@ +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 - create VM +class ProfileSetupFlowViewModel( + // TODO: Workshop 5.1.1 - pass ProfileSetupFlowScreen as parameter + // TODO: Workshop 5.1.2 - pass parent stack navigation as parameter + @Suppress("UnusedPrivateProperty") + private val climberProfileRepository: ClimberProfileRepository, +) : ViewModel() { + + // TODO: Workshop 5.4.2 - get starting step based on filled data and set a state to the navigation using getInitialScreensList + + @Suppress("MagicNumber", "UnusedPrivateMember") + private fun getStartingStep(profile: ClimberProfile) = when { + profile.boulderLevel.hasAllGrades() -> 4 + profile.sportLevel.hasAllGrades() -> 3 + profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null -> 2 + else -> 1 + } + + // TODO: Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile + val state: StateFlow = MutableStateFlow( + ProfileSetupContainerUiState( + continueEnabled = true, + currentStep = 1, + stepsCount = 4, + title = "Step #1" + ) + ) + + @Suppress("UnusedParameter") + // TODO: Workshop 5.4.1 - implement getInitialScreensList + fun getInitialScreensList(step: Int): List = listOfNotNull() + + fun onContinueClick() { + // TODO: Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen + } + + fun onCancelClick() { + // TODO: Workshop 5.2.2 - move onCancelClick from ProfileSetupFlowScreen + } + + fun onBackClick() { + // TODO: Workshop 5.2.3 - move onBackClick from ProfileSetupFlowScreen + } + +} + +@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 +} + +fun getUiState( + navigationState: StackState, + currentStep: Int, + profile: ClimberProfile +) = ProfileSetupContainerUiState( + continueEnabled = isContinueEnabled(currentStep, profile), + currentStep = currentStep, + stepsCount = 4, + title = navigationState.stack.lastOrNull()?.let { it as? SetupStepScreen }?.title ?: "Profile Setup" +) \ 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..cb0ca1b --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt @@ -0,0 +1,111 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.terrakok.modo.navigationStateFlow +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.data.ClimberProfileRepository +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.ClimberPersonalInfoScreen +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +// Workshop 5.1 - create VM +class ProfileSetupFlowViewModelFinal( + private val restartFlow: Boolean, + // Workshop 5.1.1 - take screens as parametrs + private val profileSetupScreen: ProfileSetupFlowScreenFinal, + private val parentNavigation: StackNavContainer, + private val climberProfileRepository: ClimberProfileRepository, +) : ViewModel() { + + init { + val startStep = + @Suppress("MagicNumber") + if (restartFlow) { + 1 + } else { + climberProfileRepository.climberProfile.value.let { profile -> + when { + profile.boulderLevel.hasAllGrades() -> 4 + profile.sportLevel.hasAllGrades() -> 3 + profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null -> 2 + else -> 1 + } + } + } + profileSetupScreen.setState(StackState(getInitialScreensList(startStep))) + } + + val state: StateFlow = combine( + profileSetupScreen.navigationStateFlow(), + climberProfileRepository.climberProfile + ) { navigationState, profile -> + getUiState(navigationState, profile) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + getUiState(profileSetupScreen.navigationState, climberProfileRepository.climberProfile.value) + ) + + @Suppress("MagicNumber") + fun getInitialScreensList(step: Int) = listOfNotNull( + ClimberPersonalInfoScreen(), + if (step >= 2) ClimbingLevelScreen(ClimbingType.Sport) else null, + if (step >= 3) ClimbingLevelScreen(ClimbingType.Bouldering) else null, + if (step >= 4) TrainingRecommendationsScreen() else null + ) + + private 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" + ) + + // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen + fun onContinueClick() { + when (val screen = profileSetupScreen.navigationState.stack.lastOrNull()) { + is ClimberPersonalInfoScreen -> { + profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) + } + is ClimbingLevelScreen -> { + if (screen.climbingType == ClimbingType.Bouldering) { + profileSetupScreen.forward(TrainingRecommendationsScreen()) + } else { + profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) + } + } + is TrainingRecommendationsScreen -> { + 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 (profileSetupScreen.navigationState.stack.size > 1) { + profileSetupScreen.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/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..d23020d --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/ui/CommonUi.kt @@ -0,0 +1,94 @@ +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, +) { + TitleCell(title, modifier) { + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number + ), + modifier = Modifier.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/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 From f5fbf8eb8c601c59384543bfc4281ca97e4e1867 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Tue, 3 Sep 2024 18:13:53 +0200 Subject: [PATCH 13/36] Enhanced workshop --- .../modo/animation/MultiScreenTransitions.kt | 49 +++++++++++++ ...ransition.kt => StackScreenTransitions.kt} | 21 +++--- .../github/terrakok/modo/stack/StackScreen.kt | 33 ++++----- .../screens/containers/SampleMultiScreen.kt | 2 +- .../sample/screens/containers/SampleStack.kt | 5 +- .../ikarenkov/workshop/WorkshopActivity.kt | 1 + .../workshop/screens/SampleScreen.kt | 9 ++- .../workshop/screens/SampleScreenFinal.kt | 4 +- .../workshop/screens/main/MainTabScreen.kt | 5 +- .../screens/main/MainTabScreenFinal.kt | 6 +- .../ClimberPersonalInfoScreen.kt | 39 ++++++++++- .../ClimberPersonalInfoScreenFinal.kt | 69 +++++++++++++++++++ .../profile_setup/ProfileSetupFlowScreen.kt | 5 +- .../ProfileSetupFlowScreenFinal.kt | 4 +- .../ProfileSetupFlowViewModelFinal.kt | 22 ++---- .../github/ikarenkov/workshop/ui/CommonUi.kt | 3 +- 16 files changed, 216 insertions(+), 61 deletions(-) create mode 100644 modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt rename modo-compose/src/main/java/com/github/terrakok/modo/animation/{SlideTransition.kt => StackScreenTransitions.kt} (80%) create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/personal_data/ClimberPersonalInfoScreenFinal.kt 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/SlideTransition.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt similarity index 80% rename from modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt rename to modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt index ff4a2f7..90b095e 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/SlideTransition.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt @@ -11,7 +11,6 @@ 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.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -29,7 +28,8 @@ fun ComposeRendererScope.SlideTransition( screenModifier: Modifier = Modifier, pushDirection: AnimatedContentTransitionScope.SlideDirection = Left, popDirection: AnimatedContentTransitionScope.SlideDirection = pushDirection.opposite(), - animationSpec: FiniteAnimationSpec = tween(durationMillis = 1000), + slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), + fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } ) { ScreenTransition( @@ -38,18 +38,17 @@ fun ComposeRendererScope.SlideTransition( transitionSpec = { val transitionType: StackTransitionType = calculateStackTransitionType() when { - transitionType == StackTransitionType.Replace -> { - scaleIn(initialScale = 2f) + fadeIn() togetherWith fadeOut() - } - oldState?.stack?.last() is DialogScreen || newState?.stack?.last() is DialogScreen -> { - fadeIn() togetherWith fadeOut() + 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 = animationSpec) togetherWith - slideOutOfContainer(pushDirection, animationSpec = animationSpec) - else -> slideIntoContainer(popDirection, animationSpec = animationSpec) togetherWith - slideOutOfContainer(popDirection, animationSpec = animationSpec) + StackTransitionType.Push -> slideIntoContainer(pushDirection, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(pushDirection, animationSpec = slideAnimationSpec) + else -> slideIntoContainer(popDirection, animationSpec = slideAnimationSpec) togetherWith + slideOutOfContainer(popDirection, animationSpec = slideAnimationSpec) } } } 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 027032f..d297111 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 @@ -72,24 +72,25 @@ abstract class StackScreen( dialogModifier: Modifier = Modifier, content: RendererContent = defaultRendererContent ) { - StackBackHandler() - - val screensToRender: ScreensToRender by rememberScreensToRender() - screensToRender.screen?.let { screen -> - Content(screen, modifier, content) - } - val dialogPlaceHolder = rememberSaveable { - OptionalScreen(provideDialogPlaceholderScreen()) - }.screen - val dialogs = remember { - derivedStateOf { - screensToRender.dialogs.ifEmpty { - listOfNotNull(dialogPlaceHolder) + Box { + StackBackHandler() + val screensToRender: ScreensToRender by rememberScreensToRender() + screensToRender.screen?.let { screen -> + Content(screen, modifier, content) + } + val dialogPlaceHolder = rememberSaveable { + OptionalScreen(provideDialogPlaceholderScreen()) + }.screen + val dialogs = remember { + derivedStateOf { + screensToRender.dialogs.ifEmpty { + listOfNotNull(dialogPlaceHolder) + } } } - } - for (dialog in dialogs.value) { - RenderDialog(dialog, content, dialogModifier) + for (dialog in dialogs.value) { + RenderDialog(dialog, content, dialogModifier) + } } } 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 90c67cd..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 @@ -56,7 +56,10 @@ open class SampleStack( @Composable override fun Content(modifier: Modifier) { LogLifecycle() - TopScreenContent(modifier) { modifier -> + TopScreenContent( + modifier, + dialogModifier = modifier.fillMaxSize() + ) { modifier -> SlideTransition(modifier) } } 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 index f207ae6..8b21c52 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopActivity.kt @@ -12,6 +12,7 @@ class WorkshopActivity : AppCompatActivity() { 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 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 index 2918150..2b99b8e 100644 --- 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 @@ -2,8 +2,11 @@ package io.github.ikarenkov.workshop.screens // 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 QuickStartScreen class Parcelable -// TODO: Workshop 1.2.4 - implement Content function with QuickStartScreenContent composable +// 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 3.1.4 - navigate to MainTabScreen from QuickStartScreen \ No newline at end of file +// 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 \ 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 index bb3ef33..c0bc564 100644 --- 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 @@ -10,7 +10,7 @@ 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 QuickStartScreen class Parcelable +// Workshop 1.2.3 - use @Parcelize annotation to make SampleScreen class Parcelable @Parcelize class SampleScreenFinal( // You can pass argiment as a constructor parameter @@ -23,7 +23,7 @@ class SampleScreenFinal( override fun Content(modifier: Modifier) { // Taking a nearest stack navigation container val stackNavigation = LocalStackNavigation.current - // Workshop 1.2.4 - implement Content function with QuickStartScreenContent composable + // Workshop 1.2.4 - implement Content function with SampleScreenContent composable SampleScreenContent( modifier = modifier, screenIndex = screenIndex, 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 index 3694051..575542f 100644 --- 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 @@ -3,8 +3,9 @@ package io.github.ikarenkov.workshop.screens.main // 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 QuickStartScreen +// 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 \ No newline at end of file +// TODO: Workshop 3.4 - use navigation state to access selected tab position +// TODO: Workshop 3.5 - support animation using build-in SlideTransition \ 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 index 9f0840e..fbe15a4 100644 --- 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 @@ -4,6 +4,7 @@ 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.selectContainer @@ -38,7 +39,10 @@ class MainTabScreenFinal( Modifier .padding(paddingValues) .fillMaxSize() - ) + ) { modifier -> + // Workshop 3.5 - support animation using build-in SlideTransition + SlideTransition(modifier) + } } } 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 index 8a84139..536bec4 100644 --- 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 @@ -16,6 +16,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -24,9 +25,14 @@ 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.platform.LocalSoftwareKeyboardController 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.ScreenKey import com.github.terrakok.modo.generateScreenKey import io.github.ikarenkov.workshop.screens.profile_setup.SetupStepScreen @@ -49,8 +55,21 @@ class ClimberPersonalInfoScreen( 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 -> + // TODO: Workshop 6.1 - use ON_RESUME and ON_PAUSE events to show and hide the keyboard + } + 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(), @@ -67,7 +86,7 @@ class ClimberPersonalInfoScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ClimberProfileSetupScreenContent( +fun ClimberProfileSetupScreenContent( dateOfBirth: Date?, setDateOfBirth: (Date) -> Unit, height: String, @@ -75,11 +94,27 @@ private fun ClimberProfileSetupScreenContent( 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, @@ -94,8 +129,6 @@ private fun ClimberProfileSetupScreenContent( }, modifier = Modifier.fillMaxWidth(), ) - InputNumRow("Height", height, setHeight, "sm", Modifier.fillMaxWidth()) - InputNumRow("Weight", weight, setWeight, "kg", Modifier.fillMaxWidth()) } } 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/profile_setup/ProfileSetupFlowScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt index 599d599..e2ca5c3 100644 --- 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 @@ -7,16 +7,15 @@ import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import kotlinx.parcelize.Parcelize -// TODO: Workshop 4.1.1 - create ProfileSetupFlowScreen inheriting from StackScreen @Parcelize class ProfileSetupFlowScreen( -// TODO: Workshop 4.1.2 - implement navModel in constructor and pass it to StackScreen, use ClimberPersonalInfoScreen as initial screen +// TODO: Workshop 4.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 4.4 - use navigation state to retrieve current step + // TODO: Workshop 4.4 - use navigation state to retrieve current step and title val state = ProfileSetupContainerUiState( title = "Step #1", currentStep = 1, 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 index cec1436..1028076 100644 --- 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 @@ -14,7 +14,7 @@ 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.ClimberPersonalInfoScreen +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 @@ -25,7 +25,7 @@ import org.koin.core.parameter.parametersOf class ProfileSetupFlowScreenFinal( private val restartFlow: Boolean = false, // Workshop 4.1.1 - stack nav model with initial screen - private val navModel: StackNavModel = StackNavModel(ClimberPersonalInfoScreen()) + private val navModel: StackNavModel = StackNavModel(ClimberPersonalInfoScreenFinal()) ) : StackScreen(navModel) { // FIXME 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 index cb0ca1b..f01fad5 100644 --- 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 @@ -14,6 +14,7 @@ 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.ClimberPersonalInfoScreen +import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreenFinal import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -59,7 +60,7 @@ class ProfileSetupFlowViewModelFinal( @Suppress("MagicNumber") fun getInitialScreensList(step: Int) = listOfNotNull( - ClimberPersonalInfoScreen(), + ClimberPersonalInfoScreenFinal(), if (step >= 2) ClimbingLevelScreen(ClimbingType.Sport) else null, if (step >= 3) ClimbingLevelScreen(ClimbingType.Bouldering) else null, if (step >= 4) TrainingRecommendationsScreen() else null @@ -77,20 +78,11 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen fun onContinueClick() { - when (val screen = profileSetupScreen.navigationState.stack.lastOrNull()) { - is ClimberPersonalInfoScreen -> { - profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) - } - is ClimbingLevelScreen -> { - if (screen.climbingType == ClimbingType.Bouldering) { - profileSetupScreen.forward(TrainingRecommendationsScreen()) - } else { - profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) - } - } - is TrainingRecommendationsScreen -> { - parentNavigation.back() - } + when (profileSetupScreen.navigationState.stack.size) { + 1 -> profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) + 2 -> profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) + 3 -> profileSetupScreen.forward(TrainingRecommendationsScreen()) + else -> parentNavigation.back() } } 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 index d23020d..7717b09 100644 --- 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 @@ -48,6 +48,7 @@ fun InputNumRow( onValueChange: (String) -> Unit, valueName: String, modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier ) { TitleCell(title, modifier) { val interactionSource = remember { MutableInteractionSource() } @@ -59,7 +60,7 @@ fun InputNumRow( autoCorrectEnabled = false, keyboardType = KeyboardType.Number ), - modifier = Modifier.width(100.dp), + modifier = textFieldModifier.width(100.dp), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End) ) { OutlinedTextFieldDefaults.DecorationBox( From d478870bb26e70a261586b06c72a93ac32b002ca Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Tue, 3 Sep 2024 18:42:14 +0200 Subject: [PATCH 14/36] Workshop: step for custom ContainerScreen --- .../ikarenkov/workshop/di/RootModule.kt | 4 +- .../screens/profile/EnhancedProfileScreen.kt | 49 +++++------- .../profile/EnhancedProfileScreenFinal.kt | 68 +++++++++++++++++ .../profile/EnhancedProfileViewModel.kt | 16 ---- .../profile/EnhancedProfileViewModelFinal.kt | 75 +++++++++++++++++++ 5 files changed, 163 insertions(+), 49 deletions(-) create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt 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 index 20951fb..3926a6c 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -11,7 +12,8 @@ import org.koin.dsl.module val rootModule = module { single { ClimberProfileRepository() } viewModel { ClimbingLevelViewModel(it.get(), get()) } - viewModel { EnhancedProfileViewModel(it.get(), get()) } + viewModel { EnhancedProfileViewModel(get()) } + viewModel { EnhancedProfileViewModelFinal(it.get(), get()) } viewModel { ClimberPersonalInfoViewModel(get()) } // TODO: Workshop 5.1.2 - di define ProfileSetupViewModel // viewModel { ProfileSetupViewModel(it.get(), it.get(), it.get(), get()) } 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 index 9e45339..0099703 100644 --- 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 @@ -20,8 +20,8 @@ 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.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.lazylist.screenItem import com.github.terrakok.modo.stack.LocalStackNavigation @@ -33,21 +33,26 @@ import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScr 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 EnhancedProfileScreen( - private val navModel: NavModel = NavModel(EnhancedProfileNavigationState()) -) : ContainerScreen( + 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() { - parametersOf(this) - } + val viewModel = koinViewModel() val state by viewModel.state.collectAsState() EnhancedProfileContent( name = state.name.orEmpty(), @@ -61,6 +66,7 @@ class EnhancedProfileScreen( }, modifier = modifier ) { + // TODO: Workshop 6.2.5 - display screens inside LazyList using build-in fun screenItem and InternalContent(screen) navigationState.climbingProfileScreen?.let { screen -> screenItem(screen) { Card { @@ -87,7 +93,7 @@ class EnhancedProfileScreen( } @Composable -private fun EnhancedProfileContent( +fun EnhancedProfileContent( name: String, description: String, finishedClimbingSetup: Boolean, @@ -146,6 +152,7 @@ private fun EnhancedProfileContent( } } +// TODO: Workshop 6.2.2 - define navigation state @Parcelize data class EnhancedProfileNavigationState( val climbingProfileScreen: ClimberPersonalInfoScreen? = null, @@ -161,30 +168,8 @@ data class EnhancedProfileNavigationState( } -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 - } - ) - -} +// TODO: Workshop 6.2.3 - define navigation action +class EnhancedProfileNavigationActionNoOp() : NavigationAction @Preview @Composable 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..fb801d3 --- /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.TrainingRecommendationsScreen +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(TrainingRecommendationsScreen()) + }, + 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 index 2477860..495552f 100644 --- 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 @@ -6,10 +6,8 @@ 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 -import kotlinx.coroutines.launch class EnhancedProfileViewModel( - private val enhancedProfileScreen: EnhancedProfileScreen, private val climberProfileRepository: ClimberProfileRepository ) : ViewModel() { @@ -18,20 +16,6 @@ class EnhancedProfileViewModel( it.toUiState() } - init { - viewModelScope.launch { - climberProfileRepository.climberProfile.collect { profile -> - enhancedProfileScreen.dispatch( - EnhancedProfileNavigationAction( - showClimberProfile = profile.dateOfBirth != null, - showBoulderLever = profile.boulderLevel.hasAllGrades(), - showLeadLevel = profile.sportLevel.hasAllGrades() - ) - ) - } - } - } - private fun ClimberProfile.toUiState(): UiState = UiState( name = name, description = description, 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 From f831c89644e92c71666d397f525563293c17eaf7 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 4 Sep 2024 17:26:51 +0200 Subject: [PATCH 15/36] Workshop: added dialog sample --- .../github/terrakok/modo/stack/StackScreen.kt | 32 +++++++++---------- .../TrainingRecommendationsDialogScreen.kt | 19 +++++++++++ ...rainingRecommendationsDialogScreenFinal.kt | 27 ++++++++++++++++ .../screens/TrainingRecommendationsScreen.kt | 2 +- .../screens/main/MainTabScreenFinal.kt | 3 +- .../screens/profile/EnhancedProfileScreen.kt | 23 +------------ .../profile/EnhancedProfileScreenFinal.kt | 3 +- .../profile_setup/ProfileSetupFlowContent.kt | 30 ++++++++++++++++- .../ProfileSetupFlowScreenFinal.kt | 8 ++--- 9 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreen.kt create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreenFinal.kt 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 d297111..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 @@ -72,25 +72,23 @@ abstract class StackScreen( dialogModifier: Modifier = Modifier, content: RendererContent = defaultRendererContent ) { - Box { - StackBackHandler() - val screensToRender: ScreensToRender by rememberScreensToRender() - screensToRender.screen?.let { screen -> - Content(screen, modifier, content) - } - val dialogPlaceHolder = rememberSaveable { - OptionalScreen(provideDialogPlaceholderScreen()) - }.screen - val dialogs = remember { - derivedStateOf { - screensToRender.dialogs.ifEmpty { - listOfNotNull(dialogPlaceHolder) - } + StackBackHandler() + val screensToRender: ScreensToRender by rememberScreensToRender() + screensToRender.screen?.let { screen -> + Content(screen, modifier, content) + } + val dialogPlaceHolder = rememberSaveable { + OptionalScreen(provideDialogPlaceholderScreen()) + }.screen + val dialogs = remember { + derivedStateOf { + screensToRender.dialogs.ifEmpty { + listOfNotNull(dialogPlaceHolder) } } - for (dialog in dialogs.value) { - RenderDialog(dialog, content, dialogModifier) - } + } + for (dialog in dialogs.value) { + RenderDialog(dialog, content, dialogModifier) } } 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..333cfae --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/TrainingRecommendationsDialogScreenFinal.kt @@ -0,0 +1,27 @@ +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 +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 index 1a5f07d..946b3e9 100644 --- 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 @@ -89,7 +89,7 @@ val recommendations } @Composable -private fun TrainingRecommendationsContent( +fun TrainingRecommendationsContent( modifier: Modifier = Modifier ) { Text( 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 index fbe15a4..ea2da35 100644 --- 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 @@ -10,6 +10,7 @@ import com.github.terrakok.modo.multiscreen.MultiScreenNavModel import com.github.terrakok.modo.multiscreen.selectContainer import io.github.ikarenkov.workshop.screens.SampleScreenFinal import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreen +import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreenFinal import kotlinx.parcelize.Parcelize // Workshop 3.1 - create main tab screen @@ -18,7 +19,7 @@ class MainTabScreenFinal( // Workshop 3.1.1 - define initial state private val navModel: MultiScreenNavModel = MultiScreenNavModel( SampleScreenFinal(0), - EnhancedProfileScreen(), + EnhancedProfileScreenFinal(), selected = 0 ) ) : MultiScreen(navModel) { 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 index 0099703..67c0a27 100644 --- 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 @@ -62,32 +62,11 @@ class EnhancedProfileScreen( navigation.forward(ProfileSetupFlowScreenFinal(restart)) }, onViewInsightsClick = { - navigation.forward(TrainingRecommendationsScreen()) + // TODO: Workshop 6.3 - OpenDialog }, modifier = modifier ) { // TODO: Workshop 6.2.5 - display screens inside LazyList using build-in fun screenItem and InternalContent(screen) - 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) - } - } - } } } } 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 index fb801d3..e2e1030 100644 --- 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 @@ -10,6 +10,7 @@ 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.TrainingRecommendationsScreen import io.github.ikarenkov.workshop.screens.profile_setup.ProfileSetupFlowScreenFinal import kotlinx.parcelize.Parcelize @@ -38,7 +39,7 @@ class EnhancedProfileScreenFinal( navigation.forward(ProfileSetupFlowScreenFinal(restart)) }, onViewInsightsClick = { - navigation.forward(TrainingRecommendationsScreen()) + navigation.forward(TrainingRecommendationsDialogScreenFinal()) }, modifier = modifier ) { diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt index 1f39600..d42cfa6 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt @@ -6,8 +6,10 @@ 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 @@ -29,6 +31,7 @@ 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 io.github.ikarenkov.workshop.screens.TrainingRecommendationsContent import io.github.ikarenkov.workshop.ui.progress.ProgressBar @OptIn(ExperimentalMaterial3Api::class) @@ -94,7 +97,9 @@ fun ProfileSetupFlowContainerContent( .fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween ) { - content(Modifier.weight(1f)) + Box(Modifier.weight(1f)) { + content(Modifier.fillMaxHeight()) + } Button( onClick = onContinueClick, enabled = state.continueEnabled, @@ -140,4 +145,27 @@ private fun PreviewProfileSetupContainer() { } } } +} + +@Preview +@Composable +private 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 index 1028076..483e144 100644 --- 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 @@ -29,8 +29,8 @@ class ProfileSetupFlowScreenFinal( ) : StackScreen(navModel) { // FIXME - @ExperimentalModoApi - override fun provideDialogPlaceholderScreen(): DialogScreen? = null +// @ExperimentalModoApi +// override fun provideDialogPlaceholderScreen(): DialogScreen? = null @Suppress("MagicNumber") @Composable @@ -40,9 +40,7 @@ class ProfileSetupFlowScreenFinal( val viewModel = koinViewModel { parametersOf(restartFlow, this, parentNavigation) } val state by viewModel.state.collectAsState() ProfileSetupFlowContainerContent( - modifier = modifier - .fillMaxHeight(0.8f) - .clip(RoundedCornerShape(16.dp)), + modifier = modifier, state = state, onContinueClick = { // Workshop 4.2.1 - navigation based on selected screen From 26aab6e9664575e7063f120cfc6f4678e0ea0b62 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 4 Sep 2024 18:11:28 +0200 Subject: [PATCH 16/36] Renamed multiscreen actions --- .../modo/multiscreen/MultiScreenActions.kt | 33 ++++++++++++++++--- .../screens/main/MainTabScreenFinal.kt | 6 ++-- 2 files changed, 30 insertions(+), 9 deletions(-) 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..57dc317 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,40 @@ 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") +) +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") +) +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/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 index ea2da35..16fb448 100644 --- 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 @@ -7,9 +7,8 @@ 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.selectContainer +import com.github.terrakok.modo.multiscreen.selectScreen import io.github.ikarenkov.workshop.screens.SampleScreenFinal -import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreen import io.github.ikarenkov.workshop.screens.profile.EnhancedProfileScreenFinal import kotlinx.parcelize.Parcelize @@ -32,7 +31,7 @@ class MainTabScreenFinal( selectedTabPos = navigationState.selected, onTabClick = { pos -> // Workshop 3.3 - navigate between tabs - selectContainer(pos) + selectScreen(pos) } ) { paddingValues -> // Workshop 3.2 - display selected screen @@ -44,7 +43,6 @@ class MainTabScreenFinal( // Workshop 3.5 - support animation using build-in SlideTransition SlideTransition(modifier) } - } } } \ No newline at end of file From ecaf43543212b72704560168cfc69d70aa26dbd7 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 4 Sep 2024 18:18:31 +0200 Subject: [PATCH 17/36] Fixed replaceWith --- .../github/terrakok/modo/multiscreen/MultiScreenActions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 57dc317..ea83af4 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 @@ -33,12 +33,12 @@ fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenSt @Deprecated( message = "This function was renamed to setState. This function will be removed in further releases.", - replaceWith = ReplaceWith("setState") + 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") + replaceWith = ReplaceWith("selectScreen(index)") ) fun NavigationContainer.selectContainer(index: Int) = selectScreen(index) From 5aac310b4bb30fa53ffce3bf2da6d1d9d202bdc3 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Sun, 22 Sep 2024 16:08:56 +0200 Subject: [PATCH 18/36] Improved workshop --- .../ikarenkov/workshop/core/StateFlowEtx.kt | 16 +- .../ikarenkov/workshop/di/RootModule.kt | 2 +- .../workshop/screens/SampleScreen.kt | 42 ++++- .../workshop/screens/SampleScreenContent.kt | 41 ----- .../profile_setup/ProfileSetupFlowContent.kt | 171 ------------------ .../profile_setup/ProfileSetupFlowScreen.kt | 167 +++++++++++++++++ .../ProfileSetupFlowScreenFinal.kt | 5 - .../ProfileSetupFlowViewModel.kt | 37 ++-- .../ProfileSetupFlowViewModelFinal.kt | 48 ++--- 9 files changed, 265 insertions(+), 264 deletions(-) delete mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt delete mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt 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 index 12841d2..3eaf967 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -10,4 +11,17 @@ fun StateFlow.mapStateFlow( coroutineScope: CoroutineScope, started: SharingStarted = SharingStarted.Eagerly, transform: (T) -> R -): StateFlow = map(transform).stateIn(coroutineScope, started, transform(value)) \ No newline at end of file +): 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/di/RootModule.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt index 3926a6c..9d4a788 100644 --- 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 @@ -16,6 +16,6 @@ val rootModule = module { viewModel { EnhancedProfileViewModelFinal(it.get(), get()) } viewModel { ClimberPersonalInfoViewModel(get()) } // TODO: Workshop 5.1.2 - di define ProfileSetupViewModel -// viewModel { ProfileSetupViewModel(it.get(), it.get(), it.get(), get()) } +// 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/screens/SampleScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreen.kt index 2b99b8e..187e13b 100644 --- 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 @@ -1,5 +1,15 @@ 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 @@ -9,4 +19,34 @@ package io.github.ikarenkov.workshop.screens // 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 \ No newline at end of file +// 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/SampleScreenContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt deleted file mode 100644 index 543991f..0000000 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/SampleScreenContent.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 - -@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/profile_setup/ProfileSetupFlowContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt deleted file mode 100644 index d42cfa6..0000000 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowContent.kt +++ /dev/null @@ -1,171 +0,0 @@ -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 io.github.ikarenkov.workshop.screens.TrainingRecommendationsContent -import io.github.ikarenkov.workshop.ui.progress.ProgressBar - -@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 -private 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 -private 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/ProfileSetupFlowScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt index e2ca5c3..fc81beb 100644 --- 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 @@ -1,10 +1,41 @@ 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 @@ -43,4 +74,140 @@ class ProfileSetupFlowScreen( } } +} + +@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 index 483e144..899d35f 100644 --- 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 @@ -1,14 +1,9 @@ package io.github.ikarenkov.workshop.screens.profile_setup -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.animation.SlideTransition import com.github.terrakok.modo.stack.LocalStackNavigation 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 index fdc1833..9a87d47 100644 --- 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 @@ -42,18 +42,42 @@ class ProfileSetupFlowViewModel( fun onContinueClick() { // TODO: Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen +// when (profileSetupFlowScreen.navigationState.stack.size) { +// 1 -> ClimbingLevelScreen(ClimbingType.Sport) +// 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) +// 3 -> TrainingRecommendationsScreen() +// else -> null +// } +// ?.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, @@ -63,15 +87,4 @@ fun isContinueEnabled( 2 -> profile.sportLevel.hasAllGrades() 3 -> profile.boulderLevel.hasAllGrades() else -> true -} - -fun getUiState( - navigationState: StackState, - currentStep: Int, - profile: ClimberProfile -) = ProfileSetupContainerUiState( - continueEnabled = isContinueEnabled(currentStep, profile), - currentStep = currentStep, - stepsCount = 4, - title = navigationState.stack.lastOrNull()?.let { it as? SetupStepScreen }?.title ?: "Profile Setup" -) \ No newline at end of file +} \ 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 index f01fad5..2965872 100644 --- 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 @@ -2,29 +2,25 @@ package io.github.ikarenkov.workshop.screens.profile_setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.terrakok.modo.navigationStateFlow +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 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.ClimberPersonalInfoScreen import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScreenFinal -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn // Workshop 5.1 - create VM class ProfileSetupFlowViewModelFinal( private val restartFlow: Boolean, // Workshop 5.1.1 - take screens as parametrs - private val profileSetupScreen: ProfileSetupFlowScreenFinal, + private val profileSetupFlowScreen: ProfileSetupFlowScreenFinal, private val parentNavigation: StackNavContainer, private val climberProfileRepository: ClimberProfileRepository, ) : ViewModel() { @@ -44,19 +40,17 @@ class ProfileSetupFlowViewModelFinal( } } } - profileSetupScreen.setState(StackState(getInitialScreensList(startStep))) + profileSetupFlowScreen.setState(StackState(getInitialScreensList(startStep))) } - val state: StateFlow = combine( - profileSetupScreen.navigationStateFlow(), - climberProfileRepository.climberProfile + // 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) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - getUiState(profileSetupScreen.navigationState, climberProfileRepository.climberProfile.value) - ) + } @Suppress("MagicNumber") fun getInitialScreensList(step: Int) = listOfNotNull( @@ -66,22 +60,12 @@ class ProfileSetupFlowViewModelFinal( if (step >= 4) TrainingRecommendationsScreen() else null ) - private 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" - ) - // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen fun onContinueClick() { - when (profileSetupScreen.navigationState.stack.size) { - 1 -> profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) - 2 -> profileSetupScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) - 3 -> profileSetupScreen.forward(TrainingRecommendationsScreen()) + when (profileSetupFlowScreen.navigationState.stack.size) { + 1 -> profileSetupFlowScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) + 2 -> profileSetupFlowScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) + 3 -> profileSetupFlowScreen.forward(TrainingRecommendationsScreen()) else -> parentNavigation.back() } } @@ -93,8 +77,8 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.2.3 - move onBackClick from ProfileSetupFlowScreen fun onBackClick() { - if (profileSetupScreen.navigationState.stack.size > 1) { - profileSetupScreen.back() + if (profileSetupFlowScreen.navigationState.stack.size > 1) { + profileSetupFlowScreen.back() } else { parentNavigation.back() } From 67224017b85ba2996819b0cffa7b008d18769929 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Sun, 22 Sep 2024 16:09:35 +0200 Subject: [PATCH 19/36] Added stateFlow fun --- .../main/java/com/github/terrakok/modo/ModoModels.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 13e264d..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 @@ -3,7 +3,11 @@ 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. @@ -43,6 +47,12 @@ 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) } From 7b5457fd9c0a0e52a69cd2ba701d756b52acb99c Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 2 Oct 2024 15:50:24 +0200 Subject: [PATCH 20/36] [BUG-60] added preDispose after state update --- TestInstructions.md | 11 ++++++++++- .../java/com/github/terrakok/modo/ComposeRender.kt | 5 ++++- .../modo/sample/screens/base/ButtonsScreenContent.kt | 11 ++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/TestInstructions.md b/TestInstructions.md index 13a50f5..e125d9c 100644 --- a/TestInstructions.md +++ b/TestInstructions.md @@ -14,4 +14,13 @@ certain cases: * 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 \ No newline at end of file + * 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/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 7e3345a..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 @@ -137,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") @@ -198,7 +201,7 @@ internal class ComposeRenderer( } /** - * Called onPreDispose for removed screens + * 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) { 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 7a62ad4..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 @@ -28,6 +28,7 @@ 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 @@ -119,11 +120,11 @@ fun Screen.LogLifecycle() { lifecycleOwner.lifecycle.removeObserver(observer) } } -// LifecycleScreenEffect { -// LifecycleEventObserver { source, event -> -// logcat(tag = "LifecycleDebug") { "$screenKey LifecycleScreenEffect $event" } -// } -// } + LifecycleScreenEffect { + LifecycleEventObserver { source, event -> + logcat(tag = "LifecycleDebug") { "$screenKey LifecycleScreenEffect $event" } + } + } } @Composable From 9b06d27f376662c9dc5a3e4531c5cf2fe045c52e Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 2 Oct 2024 18:04:24 +0200 Subject: [PATCH 21/36] Fixed lint --- .../github/terrakok/modo/android/ModoScreenAndroidAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b08e95b..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 @@ -320,7 +320,7 @@ class ModoScreenAndroidAdapter private constructor( } @JvmStatic - private fun needSkipEvent(currentState: Lifecycle.State, event: Lifecycle.Event) = + 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) || From be02fc9a8cb3263baa93ab81e019ebe177f9618a Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 3 Oct 2024 13:32:08 +0200 Subject: [PATCH 22/36] Fixed detekt and lint --- gradle/libs.versions.toml | 2 +- .../github/terrakok/modo/multiscreen/MultiScreenActions.kt | 2 ++ .../screens/TrainingRecommendationsDialogScreenFinal.kt | 1 + .../screens/personal_data/ClimberPersonalInfoScreen.kt | 4 ++-- .../workshop/screens/profile/EnhancedProfileScreen.kt | 2 -- .../workshop/screens/profile/EnhancedProfileScreenFinal.kt | 1 - .../screens/profile_setup/ProfileSetupFlowViewModelFinal.kt | 1 + 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8897911..691bff5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ kotlin = "1.9.23" kotlinCompilerExtension = "1.5.12" minSdk = "21" compileSdk = "34" -koin = "4.0.0-RC1" +koin = "4.0.0" [libraries] androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" } 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 ea83af4..1f7eb47 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 @@ -40,7 +40,9 @@ fun NavigationContainer.setContainers(state 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/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 index 333cfae..581f5f6 100644 --- 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 @@ -14,6 +14,7 @@ import kotlinx.parcelize.Parcelize @OptIn(ExperimentalModoApi::class) @Parcelize +@Suppress("MagicNumbers") class TrainingRecommendationsDialogScreenFinal( override val screenKey: ScreenKey = generateScreenKey() ) : DialogScreen { 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 index 536bec4..e18c713 100644 --- 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 @@ -28,7 +28,6 @@ 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.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.LifecycleEventObserver @@ -57,7 +56,8 @@ class ClimberPersonalInfoScreen( val state by viewModel.state.collectAsState() val focusRequester = remember { FocusRequester() } val lifecycleOwner = LocalLifecycleOwner.current - val keyboardController = LocalSoftwareKeyboardController.current + // TODO: Workshop 6.1 - use ON_RESUME and ON_PAUSE events to show and hide the keyboard +// val keyboardController = LocalSoftwareKeyboardController.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> // TODO: Workshop 6.1 - use ON_RESUME and ON_PAUSE events to show and hide the keyboard 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 index 67c0a27..e097103 100644 --- 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 @@ -23,11 +23,9 @@ 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.lazylist.screenItem 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.TrainingRecommendationsScreen 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 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 index e2e1030..c678bfa 100644 --- 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 @@ -11,7 +11,6 @@ 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.TrainingRecommendationsScreen import io.github.ikarenkov.workshop.screens.profile_setup.ProfileSetupFlowScreenFinal import kotlinx.parcelize.Parcelize import org.koin.androidx.compose.koinViewModel 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 index 2965872..ae34e9c 100644 --- 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 @@ -17,6 +17,7 @@ import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScr 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 From 0db6c968d5f2a428d7131418fd5fefa841fa73d2 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 3 Oct 2024 16:21:45 +0200 Subject: [PATCH 23/36] Fixed detekt and lint --- gradle/libs.versions.toml | 2 ++ sample/src/main/AndroidManifest.xml | 6 ++++-- workshop-app/build.gradle.kts | 2 +- workshop-app/src/main/AndroidManifest.xml | 14 ++++++++------ .../workshop/screens/auth/AuthCodeScreen.kt | 2 +- .../screens/climbing_level/ClimbingLevelScreen.kt | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 691bff5..4980109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" modo = "0.10.0-alpha3" androidGradlePlugin = "8.4.0" @@ -39,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" } 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/workshop-app/build.gradle.kts b/workshop-app/build.gradle.kts index b1af607..1e7c3af 100644 --- a/workshop-app/build.gradle.kts +++ b/workshop-app/build.gradle.kts @@ -36,7 +36,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.fragment) - implementation("com.github.zj565061763:compose-wheel-picker:1.0.0-beta05") + implementation(libs.compose.wheelPicker) implementation(libs.koin.android) implementation(libs.koin.compose) diff --git a/workshop-app/src/main/AndroidManifest.xml b/workshop-app/src/main/AndroidManifest.xml index 017a216..142f366 100644 --- a/workshop-app/src/main/AndroidManifest.xml +++ b/workshop-app/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"> @@ -16,10 +18,10 @@ - - - - + + + + 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 index 4da3bf9..980594b 100644 --- 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 @@ -63,7 +63,7 @@ class AuthCodeScreen( } @Composable -private fun AuthCodeScreenContent( +internal fun AuthCodeScreenContent( onCodeEntered: (code: String) -> Unit, modifier: Modifier = Modifier, ) { 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 index 338527d..f7d2d23 100644 --- 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 @@ -66,7 +66,7 @@ class ClimbingLevelScreen( } @Composable -private fun ClimbingLevelSetupScreenContent( +internal fun ClimbingLevelSetupScreenContent( redpointGrade: FrenchScaleGrade?, onsightGrade: FrenchScaleGrade?, flashGrade: FrenchScaleGrade?, From 794ea056718a435e9ca755f30e22ee8b67f44559 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Thu, 3 Oct 2024 16:29:18 +0200 Subject: [PATCH 24/36] Fixed detekt --- .../github/terrakok/modo/multiscreen/MultiScreenActions.kt | 2 +- .../screens/TrainingRecommendationsDialogScreenFinal.kt | 2 +- .../ikarenkov/workshop/screens/main/MainTabScreenFinal.kt | 4 ++-- .../workshop/screens/profile/EnhancedProfileScreen.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 1f7eb47..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 @@ -36,11 +36,11 @@ fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenSt 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)) 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 index 581f5f6..19085ae 100644 --- 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 @@ -14,7 +14,7 @@ import kotlinx.parcelize.Parcelize @OptIn(ExperimentalModoApi::class) @Parcelize -@Suppress("MagicNumbers") +@Suppress("MagicNumber") class TrainingRecommendationsDialogScreenFinal( override val screenKey: ScreenKey = generateScreenKey() ) : DialogScreen { 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 index 16fb448..c7e045e 100644 --- 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 @@ -39,9 +39,9 @@ class MainTabScreenFinal( Modifier .padding(paddingValues) .fillMaxSize() - ) { modifier -> + ) { innerModifier -> // Workshop 3.5 - support animation using build-in SlideTransition - SlideTransition(modifier) + SlideTransition(innerModifier) } } } 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 index e097103..03cb0c7 100644 --- 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 @@ -146,7 +146,7 @@ data class EnhancedProfileNavigationState( } // TODO: Workshop 6.2.3 - define navigation action -class EnhancedProfileNavigationActionNoOp() : NavigationAction +class EnhancedProfileNavigationActionNoOp : NavigationAction @Preview @Composable From 3062798d9e737bee7ec585a3f35a995fd5a43d95 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 10:56:04 +0200 Subject: [PATCH 25/36] Improved workshop todo steps --- gradle/libs.versions.toml | 2 +- .../workshop/screens/main/MainTabContent.kt | 2 - .../ClimberPersonalInfoScreen.kt | 22 +++---- .../profile_setup/ProfileSetupFlowScreen.kt | 2 + .../workshop/utils/LifecycleUtils.kt | 59 +++++++++++++++++++ 5 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/utils/LifecycleUtils.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4980109..e964ce6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" -modo = "0.10.0-alpha3" +modo = "0.10.0-rc1" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt index b1be9f3..3c3228d 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt @@ -38,12 +38,10 @@ fun MainTabContent( IconButton( modifier = Modifier.weight(1f), onClick = { - // TODO: Workshop 3.3 - navigate between tabs onTabClick(pos) }, ) { val contentColor = LocalContentColor.current - // TODO: Workshop 3.4 - use navigation state to define UI val color by animateColorAsState( contentColor.copy( alpha = if (pos == selectedTabPos) contentColor.alpha else 0.5f 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 index e18c713..16d9e9b 100644 --- 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 @@ -16,7 +16,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -30,13 +29,12 @@ 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 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 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 @@ -55,18 +53,16 @@ class ClimberPersonalInfoScreen( val viewModel = koinViewModel() val state by viewModel.state.collectAsState() val focusRequester = remember { FocusRequester() } - val lifecycleOwner = LocalLifecycleOwner.current - // TODO: Workshop 6.1 - use ON_RESUME and ON_PAUSE events to show and hide the keyboard + // TODO: Workshop 6.1.1 - get keyboard controller // val keyboardController = LocalSoftwareKeyboardController.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - // TODO: Workshop 6.1 - use ON_RESUME and ON_PAUSE events to show and hide the keyboard - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) + 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), 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 index fc81beb..0e5da4c 100644 --- 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 @@ -48,7 +48,9 @@ class ProfileSetupFlowScreen( override fun Content(modifier: Modifier) { // 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 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) + } + } +} From 7aefe951e809b76d736191389336238695cd802b Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 12:37:49 +0200 Subject: [PATCH 26/36] Moved content of main tab to workshop file --- .../workshop/screens/main/MainTabContent.kt | 89 ------------------ .../workshop/screens/main/MainTabScreen.kt | 90 ++++++++++++++++++- 2 files changed, 89 insertions(+), 90 deletions(-) delete mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt deleted file mode 100644 index 3c3228d..0000000 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabContent.kt +++ /dev/null @@ -1,89 +0,0 @@ -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 - -@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/MainTabScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/main/MainTabScreen.kt index 575542f..1293fa3 100644 --- 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 @@ -1,5 +1,28 @@ 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 @@ -8,4 +31,69 @@ package io.github.ikarenkov.workshop.screens.main // 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 \ No newline at end of file +// 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 From c4f6a1199c893406854693dc6b08e4664a66aacd Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 12:54:58 +0200 Subject: [PATCH 27/36] Moved content of ProfileScreenContent tab to workshop file --- .../screens/profile/ProfileContent.kt | 91 ------------------- .../workshop/screens/profile/ProfileScreen.kt | 87 ++++++++++++++++++ 2 files changed, 87 insertions(+), 91 deletions(-) delete mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt deleted file mode 100644 index ea68f9a..0000000 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileContent.kt +++ /dev/null @@ -1,91 +0,0 @@ -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 - -@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/ProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/ProfileScreen.kt index 823b0c4..7a809b0 100644 --- 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 @@ -1,7 +1,31 @@ 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 @@ -23,4 +47,67 @@ class ProfileScreen( } ) } +} + +@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 From b543f4f77696304d8bf78c7c6cdd8f1b3ebe84e5 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 13:03:17 +0200 Subject: [PATCH 28/36] Moved content of main tab to workshop file --- .../screens/profile_setup/ProfileSetupFlowScreen.kt | 2 +- .../profile_setup/ProfileSetupFlowViewModelFinal.kt | 9 +++------ .../screens/profile_setup/ProfileSetupLogic.kt | 12 ++++++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupLogic.kt 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 index 0e5da4c..22d54ed 100644 --- 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 @@ -59,7 +59,7 @@ class ProfileSetupFlowScreen( modifier = modifier, state = state, onContinueClick = { - // TODO: Workshop 4.3.1 - navigation based on selected screen + // TODO: Workshop 4.3.1 - navigation based on selected screen. Use `getNextProfileSetupStepScreen`. // TODO: 5.2.1 - move to VM }, onCancelClick = { 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 index ae34e9c..c1eb3bd 100644 --- 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 @@ -63,12 +63,9 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen fun onContinueClick() { - when (profileSetupFlowScreen.navigationState.stack.size) { - 1 -> profileSetupFlowScreen.forward(ClimbingLevelScreen(ClimbingType.Sport)) - 2 -> profileSetupFlowScreen.forward(ClimbingLevelScreen(ClimbingType.Bouldering)) - 3 -> profileSetupFlowScreen.forward(TrainingRecommendationsScreen()) - else -> parentNavigation.back() - } + getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size)?.let { + profileSetupFlowScreen.forward(it) + } ?: parentNavigation.back() } // Workshop 5.2.2 - move onCancelClick from ProfileSetupFlowScreen 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..7fd3a17 --- /dev/null +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupLogic.kt @@ -0,0 +1,12 @@ +package io.github.ikarenkov.workshop.screens.profile_setup + +import io.github.ikarenkov.workshop.domain.ClimbingType +import io.github.ikarenkov.workshop.screens.TrainingRecommendationsScreen +import io.github.ikarenkov.workshop.screens.climbing_level.ClimbingLevelScreen + +fun getNextProfileSetupStepScreen(step: Int): SetupStepScreen? = when (step) { + 1 -> ClimbingLevelScreen(ClimbingType.Sport) + 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) + 3 -> TrainingRecommendationsScreen() + else -> null +} \ No newline at end of file From 34b44bb72f7138843933751fdd7bd086b6853109 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 13:28:05 +0200 Subject: [PATCH 29/36] Improved VM todo --- .../profile_setup/ProfileSetupFlowScreen.kt | 1 + .../profile_setup/ProfileSetupFlowViewModel.kt | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) 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 index 22d54ed..8db287d 100644 --- 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 @@ -46,6 +46,7 @@ class ProfileSetupFlowScreen( @Composable override fun Content(modifier: Modifier) { + // TODO: Workshop 5.1.2 - retrieve viewModel using koinViewModel // 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 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 index 9a87d47..e9eabc0 100644 --- 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 @@ -8,10 +8,11 @@ import io.github.ikarenkov.workshop.domain.ClimberProfile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -// TODO: Workshop 5.1 - create VM +// TODO: Workshop 5.1 - use VM class ProfileSetupFlowViewModel( - // TODO: Workshop 5.1.1 - pass ProfileSetupFlowScreen as parameter - // TODO: Workshop 5.1.2 - pass parent stack navigation as parameter + // 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() { @@ -42,12 +43,7 @@ class ProfileSetupFlowViewModel( fun onContinueClick() { // TODO: Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen -// when (profileSetupFlowScreen.navigationState.stack.size) { -// 1 -> ClimbingLevelScreen(ClimbingType.Sport) -// 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) -// 3 -> TrainingRecommendationsScreen() -// else -> null -// } +// getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size) // ?.let { profileSetupFlowScreen.forward(it) } // ?: parentNavigation.back() } From 14602b97f9309de34079fd5d9bd6d04bcf87f186 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 13:39:23 +0200 Subject: [PATCH 30/36] Improved workshop view model todo --- .../workshop/screens/profile_setup/ProfileSetupFlowScreen.kt | 1 + .../screens/profile_setup/ProfileSetupFlowViewModel.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 8db287d..4ccf8f4 100644 --- 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 @@ -58,6 +58,7 @@ class ProfileSetupFlowScreen( ) 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`. 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 index e9eabc0..941b03d 100644 --- 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 @@ -27,7 +27,8 @@ class ProfileSetupFlowViewModel( else -> 1 } - // TODO: Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile + // TODO: Workshop 5.3.1 - define state using navigationStateFlow and climberProfileRepository.climberProfile + // Use combineStateFlow, navigationStateStateFlow, climberProfileRepository.climberProfile and getUiState val state: StateFlow = MutableStateFlow( ProfileSetupContainerUiState( continueEnabled = true, From 0d06d837beab451d7cf752e0cca4bc04670a2061 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 13:48:07 +0200 Subject: [PATCH 31/36] Improved workshop 5 step todo --- .../main/kotlin/io/github/ikarenkov/workshop/di/RootModule.kt | 4 +++- .../workshop/screens/profile_setup/ProfileSetupFlowScreen.kt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) 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 index 9d4a788..93c1ec0 100644 --- 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 @@ -15,7 +15,9 @@ val rootModule = module { viewModel { EnhancedProfileViewModel(get()) } viewModel { EnhancedProfileViewModelFinal(it.get(), get()) } viewModel { ClimberPersonalInfoViewModel(get()) } - // TODO: Workshop 5.1.2 - di define ProfileSetupViewModel + // 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/screens/profile_setup/ProfileSetupFlowScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowScreen.kt index 4ccf8f4..b42dbfa 100644 --- 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 @@ -47,6 +47,7 @@ class ProfileSetupFlowScreen( @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 From d70d9376f7ad61816455b7c2e067633cb1367f91 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 20:09:09 +0200 Subject: [PATCH 32/36] Improved workshop 5 step --- .../ProfileSetupFlowViewModel.kt | 14 +--------- .../ProfileSetupFlowViewModelFinal.kt | 28 ++----------------- .../profile_setup/ProfileSetupLogic.kt | 19 +++++++++++++ 3 files changed, 22 insertions(+), 39 deletions(-) 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 index 941b03d..f5c0f2f 100644 --- 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 @@ -17,15 +17,7 @@ class ProfileSetupFlowViewModel( private val climberProfileRepository: ClimberProfileRepository, ) : ViewModel() { - // TODO: Workshop 5.4.2 - get starting step based on filled data and set a state to the navigation using getInitialScreensList - - @Suppress("MagicNumber", "UnusedPrivateMember") - private fun getStartingStep(profile: ClimberProfile) = when { - profile.boulderLevel.hasAllGrades() -> 4 - profile.sportLevel.hasAllGrades() -> 3 - profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null -> 2 - else -> 1 - } + // 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 @@ -38,10 +30,6 @@ class ProfileSetupFlowViewModel( ) ) - @Suppress("UnusedParameter") - // TODO: Workshop 5.4.1 - implement getInitialScreensList - fun getInitialScreensList(step: Int): List = listOfNotNull() - fun onContinueClick() { // TODO: Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen // getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size) 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 index c1eb3bd..5562763 100644 --- 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 @@ -10,10 +10,6 @@ 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 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 import kotlinx.coroutines.flow.StateFlow // Workshop 5.1 - create VM @@ -28,20 +24,8 @@ class ProfileSetupFlowViewModelFinal( init { val startStep = - @Suppress("MagicNumber") - if (restartFlow) { - 1 - } else { - climberProfileRepository.climberProfile.value.let { profile -> - when { - profile.boulderLevel.hasAllGrades() -> 4 - profile.sportLevel.hasAllGrades() -> 3 - profile.dateOfBirth != null && profile.heightSm != null && profile.weightKg != null -> 2 - else -> 1 - } - } - } - profileSetupFlowScreen.setState(StackState(getInitialScreensList(startStep))) + if (restartFlow) 1 else getProfileSetupStartingStep(climberProfileRepository.climberProfile.value) + profileSetupFlowScreen.setState(StackState(getProfileSetupInitialScreens(startStep))) } // Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile @@ -53,14 +37,6 @@ class ProfileSetupFlowViewModelFinal( getUiState(navigationState, profile) } - @Suppress("MagicNumber") - fun getInitialScreensList(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 - ) - // Workshop 5.2.1 - move onContinueClick from ProfileSetupFlowScreen fun onContinueClick() { getNextProfileSetupStepScreen(profileSetupFlowScreen.navigationState.stack.size)?.let { 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 index 7fd3a17..f0b472b 100644 --- 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 @@ -1,12 +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 From 7ce1909e1b988b719ba8f08d2f4dc7c8bd630775 Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 20:14:02 +0200 Subject: [PATCH 33/36] Fixed workshop 4 step todo --- .../workshop/screens/profile_setup/ProfileSetupFlowScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b42dbfa..2e000b2 100644 --- 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 @@ -40,7 +40,7 @@ import kotlinx.parcelize.Parcelize @Parcelize class ProfileSetupFlowScreen( -// TODO: Workshop 4.1 - implement navModel in constructor and pass it to StackScreen, use ClimberPersonalInfoScreen as initial screen +// 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 { @@ -74,7 +74,7 @@ class ProfileSetupFlowScreen( // TODO: 5.2.3 - move to VM }, ) { modifier -> - // TODO: Workshop 4.1.3 - display content + // TODO: Workshop 4.1.2 - display content // TODO: Workshop 4.5 - custom animation } } From 86ee08b2b2bdba51a281a2771002a8b98454962b Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Wed, 9 Oct 2024 20:31:31 +0200 Subject: [PATCH 34/36] Predefined state in workshop --- .../profile_setup/ProfileSetupFlowViewModel.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 index f5c0f2f..287139c 100644 --- 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 @@ -21,14 +21,13 @@ class ProfileSetupFlowViewModel( // TODO: Workshop 5.3.1 - define state using navigationStateFlow and climberProfileRepository.climberProfile // Use combineStateFlow, navigationStateStateFlow, climberProfileRepository.climberProfile and getUiState - val state: StateFlow = MutableStateFlow( - ProfileSetupContainerUiState( - continueEnabled = true, - currentStep = 1, - stepsCount = 4, - title = "Step #1" - ) - ) +// 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 From d15ebd29a9ef106fbcea6c50dc6ad333847715ad Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Fri, 11 Oct 2024 00:19:05 +0200 Subject: [PATCH 35/36] Bumped version to release --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e964ce6..8a34866 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" -modo = "0.10.0-rc1" +modo = "0.10.0" androidGradlePlugin = "8.4.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" From f86ea14fbe358a8ecad8ca9647bf37d88c4f386c Mon Sep 17 00:00:00 2001 From: ikarenkov Date: Fri, 11 Oct 2024 11:25:10 +0200 Subject: [PATCH 36/36] Fixed detekt --- .../workshop/screens/profile_setup/ProfileSetupLogic.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index f0b472b..63b2aed 100644 --- 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 @@ -8,10 +8,10 @@ import io.github.ikarenkov.workshop.screens.personal_data.ClimberPersonalInfoScr @Suppress("MagicNumber") fun getNextProfileSetupStepScreen(step: Int): SetupStepScreen? = when (step) { - 1 -> ClimbingLevelScreen(ClimbingType.Sport) - 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) - 3 -> TrainingRecommendationsScreen() - else -> null + 1 -> ClimbingLevelScreen(ClimbingType.Sport) + 2 -> ClimbingLevelScreen(ClimbingType.Bouldering) + 3 -> TrainingRecommendationsScreen() + else -> null } @Suppress("MagicNumber")