diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 30a57a5d..48e1be0e 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -15,11 +15,13 @@ - diff --git a/playground-shared/.gitignore b/compose-destinations-wear/.gitignore similarity index 100% rename from playground-shared/.gitignore rename to compose-destinations-wear/.gitignore diff --git a/compose-destinations-wear/build.gradle.kts b/compose-destinations-wear/build.gradle.kts new file mode 100644 index 00000000..8d90758e --- /dev/null +++ b/compose-destinations-wear/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +apply(from = "${rootProject.projectDir}/publish.gradle") + +android { + + compileSdk = libs.versions.compileSdk.get().toIntOrNull() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toIntOrNull() + targetSdk = libs.versions.targetSdk.get().toIntOrNull() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles.add(File("consumer-rules.pro")) + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } +} + +tasks.withType().configureEach { + kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" +} + +dependencies { + api(project(mapOf("path" to ":compose-destinations"))) + + api(libs.wear.compose.navigation) +} diff --git a/compose-destinations-wear/consumer-rules.pro b/compose-destinations-wear/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/compose-destinations-wear/gradle.properties b/compose-destinations-wear/gradle.properties new file mode 100644 index 00000000..b96767b2 --- /dev/null +++ b/compose-destinations-wear/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=wear-core +POM_NAME=wear-core \ No newline at end of file diff --git a/compose-destinations-wear/proguard-rules.pro b/compose-destinations-wear/proguard-rules.pro new file mode 100644 index 00000000..ff59496d --- /dev/null +++ b/compose-destinations-wear/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/compose-destinations-wear/src/main/AndroidManifest.xml b/compose-destinations-wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1b3a8438 --- /dev/null +++ b/compose-destinations-wear/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/compose-destinations-wear/src/main/java/com/ramcosta/composedestinations/wear/WearNavHostEngine.kt b/compose-destinations-wear/src/main/java/com/ramcosta/composedestinations/wear/WearNavHostEngine.kt new file mode 100644 index 00000000..84966b9d --- /dev/null +++ b/compose-destinations-wear/src/main/java/com/ramcosta/composedestinations/wear/WearNavHostEngine.kt @@ -0,0 +1,160 @@ +package com.ramcosta.composedestinations.wear + +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation.* +import androidx.wear.compose.navigation.* +import com.ramcosta.composedestinations.annotation.InternalDestinationsApi +import com.ramcosta.composedestinations.manualcomposablecalls.DestinationLambda +import com.ramcosta.composedestinations.manualcomposablecalls.ManualComposableCalls +import com.ramcosta.composedestinations.navigation.DependenciesContainerBuilder +import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.scope.DestinationScopeImpl +import com.ramcosta.composedestinations.spec.* + +/** + * Returns the [WearNavHostEngine] to be used with Wear OS apps. + */ +@Composable +fun rememberWearNavHostEngine( + state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(), +): NavHostEngine { + val defaultNavHostEngine = rememberNavHostEngine() + + return remember { + WearNavHostEngine(defaultNavHostEngine, state) + } +} + +internal class WearNavHostEngine( + private val defaultNavHostEngine: NavHostEngine, + private val state: SwipeDismissableNavHostState, +) : NavHostEngine { + + override val type = NavHostEngine.Type.WEAR + + @Composable + override fun rememberNavController( + vararg navigators: Navigator + ) = + androidx.navigation.compose.rememberNavController(remember { WearNavigator() }, *navigators) + + @Composable + override fun NavHost( + modifier: Modifier, + route: String, + startRoute: Route, + navController: NavHostController, + builder: NavGraphBuilder.() -> Unit + ) { + SwipeDismissableNavHost( + navController = navController, + startDestination = startRoute.route, + modifier = modifier, + route = route, + state = state, + builder = builder + ) + } + + override fun NavGraphBuilder.navigation( + navGraph: NavGraphSpec, + builder: NavGraphBuilder.() -> Unit + ) { + with(defaultNavHostEngine) { navigation(navGraph, builder) } + } + + @OptIn(ExperimentalAnimationApi::class, InternalDestinationsApi::class) + override fun NavGraphBuilder.composable( + destination: DestinationSpec, + navController: NavHostController, + dependenciesContainerBuilder: @Composable DependenciesContainerBuilder<*>.() -> Unit, + manualComposableCalls: ManualComposableCalls, + ) { + when (destination.style) { + is DestinationStyle.Runtime, + is DestinationStyle.Default -> { + addComposable( + destination, + navController, + dependenciesContainerBuilder, + manualComposableCalls + ) + } + + is DestinationStyle.Activity -> { + with(defaultNavHostEngine) { + composable(destination, navController, dependenciesContainerBuilder, manualComposableCalls) + } + } + + is DestinationStyle.Dialog, + is DestinationStyle.Animated, + is DestinationStyle.BottomSheet -> { + throw IllegalStateException("${destination.style.javaClass.name} cannot be used on Wear OS version of the core library!") + } + } + } + + private fun NavGraphBuilder.addComposable( + destination: DestinationSpec, + navController: NavHostController, + dependenciesContainerBuilder: @Composable DependenciesContainerBuilder<*>.() -> Unit, + manualComposableCalls: ManualComposableCalls, + ) { + @SuppressLint("RestrictedApi") + val contentLambda = manualComposableCalls[destination.baseRoute] + + composable( + route = destination.route, + arguments = destination.arguments, + deepLinks = destination.deepLinks + ) { navBackStackEntry -> + CallComposable( + destination, + navController, + navBackStackEntry, + dependenciesContainerBuilder, + contentLambda + ) + } + } + + internal class WearDestinationScope( + destination: DestinationSpec, + navBackStackEntry: NavBackStackEntry, + navController: NavController, + ) : DestinationScopeImpl( + destination, + navBackStackEntry, + navController, + ) + + @Suppress("UNCHECKED_CAST") + @Composable + private fun CallComposable( + destination: DestinationSpec, + navController: NavHostController, + navBackStackEntry: NavBackStackEntry, + dependenciesContainerBuilder: @Composable DependenciesContainerBuilder<*>.() -> Unit, + contentLambda: DestinationLambda<*>? + ) { + val scope = remember { + WearDestinationScope( + destination, + navBackStackEntry, + navController + ) + } + + if (contentLambda == null) { + with(destination) { scope.Content(dependenciesContainerBuilder) } + } else { + contentLambda as DestinationLambda + contentLambda(scope) + } + } +} \ No newline at end of file diff --git a/compose-destinations/src/main/java/com/ramcosta/composedestinations/manualcomposablecalls/ManualComposableCallsBuilder.kt b/compose-destinations/src/main/java/com/ramcosta/composedestinations/manualcomposablecalls/ManualComposableCallsBuilder.kt index 40f0bce2..5856b449 100644 --- a/compose-destinations/src/main/java/com/ramcosta/composedestinations/manualcomposablecalls/ManualComposableCallsBuilder.kt +++ b/compose-destinations/src/main/java/com/ramcosta/composedestinations/manualcomposablecalls/ManualComposableCallsBuilder.kt @@ -113,7 +113,7 @@ class ManualComposableCallsBuilder internal constructor( private fun ManualComposableCallsBuilder.validateAnimated( destination: DestinationSpec<*> ) { - if (engineType == NavHostEngine.Type.DEFAULT) { + if (engineType != NavHostEngine.Type.ANIMATED) { error("'animatedComposable' can only be called with a 'AnimatedNavHostEngine'") } @@ -125,7 +125,7 @@ private fun ManualComposableCallsBuilder.validateAnimated( private fun ManualComposableCallsBuilder.validateBottomSheet( destination: DestinationSpec<*> ) { - if (engineType == NavHostEngine.Type.DEFAULT) { + if (engineType != NavHostEngine.Type.ANIMATED) { error("'bottomSheetComposable' can only be called with a 'AnimatedNavHostEngine'") } diff --git a/compose-destinations/src/main/java/com/ramcosta/composedestinations/spec/NavHostEngine.kt b/compose-destinations/src/main/java/com/ramcosta/composedestinations/spec/NavHostEngine.kt index 79e6baff..53f18f41 100644 --- a/compose-destinations/src/main/java/com/ramcosta/composedestinations/spec/NavHostEngine.kt +++ b/compose-destinations/src/main/java/com/ramcosta/composedestinations/spec/NavHostEngine.kt @@ -29,7 +29,13 @@ interface NavHostEngine { * The engine you get if using "io.github.raamcosta.compose-destinations:animations-core" * and calling `rememberAnimatedNavHostEngine` */ - ANIMATED + ANIMATED, + + /** + * The engine you get if using "io.github.raamcosta.compose-destinations:wear-core" + * and calling `rememberWearNavHostEngine` + */ + WEAR } /** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ff8f407..832a8a26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ ktxSerialization = "1.4.1" mockk = "1.13.2" compileTesting = "1.4.9" +composeWear = "1.1.0" [plugins] dependencyCheckPlugin = { id = "com.github.ben-manes.versions", version.ref = "dependencyCheckPluginVersion" } @@ -69,3 +70,10 @@ test-junit = { module = "junit:junit", version.ref = "junit" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } test-kotlinCompile = { module = "com.github.tschuchortdev:kotlin-compile-testing", version.ref = "compileTesting" } test-kotlinCompileKsp = { module = "com.github.tschuchortdev:kotlin-compile-testing-ksp", version.ref = "compileTesting" } + +# Wear + +wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "composeWear" } +wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "composeWear" } +wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "composeWear" } +wear-input = { module = "androidx.wear:wear-input", version.ref = "composeWear" } diff --git a/playground-core/.gitignore b/playground-core/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/playground-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/playground-shared/build.gradle.kts b/playground-core/build.gradle.kts similarity index 72% rename from playground-shared/build.gradle.kts rename to playground-core/build.gradle.kts index cc30663e..e8259cee 100644 --- a/playground-shared/build.gradle.kts +++ b/playground-core/build.gradle.kts @@ -1,10 +1,9 @@ plugins { id("java-library") - id("kotlin") - kotlin("plugin.serialization") + id("org.jetbrains.kotlin.jvm") } java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 -} +} \ No newline at end of file diff --git a/playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/BlogPostArgs.kt b/playground-core/src/main/java/com/ramcosta/playground/core/BlogPostArgs.kt similarity index 51% rename from playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/BlogPostArgs.kt rename to playground-core/src/main/java/com/ramcosta/playground/core/BlogPostArgs.kt index daf32966..fa73b258 100644 --- a/playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/BlogPostArgs.kt +++ b/playground-core/src/main/java/com/ramcosta/playground/core/BlogPostArgs.kt @@ -1,4 +1,4 @@ -package com.ramcosta.samples.playgroundshared +package com.ramcosta.playground.core data class BlogPostArgs( val slug: String diff --git a/playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/WithDefaultValueArgs.kt b/playground-core/src/main/java/com/ramcosta/playground/core/WithDefaultValueArgs.kt similarity index 60% rename from playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/WithDefaultValueArgs.kt rename to playground-core/src/main/java/com/ramcosta/playground/core/WithDefaultValueArgs.kt index c8d6a8b5..08ae29f7 100644 --- a/playground-shared/src/main/java/com/ramcosta/samples/playgroundshared/WithDefaultValueArgs.kt +++ b/playground-core/src/main/java/com/ramcosta/playground/core/WithDefaultValueArgs.kt @@ -1,4 +1,4 @@ -package com.ramcosta.samples.playgroundshared +package com.ramcosta.playground.core data class WithDefaultValueArgs( val isCreate: Boolean = false, diff --git a/playground/build.gradle.kts b/playground/build.gradle.kts index a782dbc2..a78ff589 100644 --- a/playground/build.gradle.kts +++ b/playground/build.gradle.kts @@ -96,7 +96,7 @@ android { dependencies { implementation(project(mapOf("path" to ":compose-destinations-animations"))) - implementation(project(mapOf("path" to ":playground-shared"))) + implementation(project(mapOf("path" to ":playground-core"))) ksp(project(":compose-destinations-ksp")) implementation(libs.androidMaterial) diff --git a/playground/src/test/kotlin/com/ramcosta/composedestinations/ksp/ProcessorProviderTests.kt b/playground/src/test/kotlin/com/ramcosta/composedestinations/ksp/ProcessorProviderTests.kt index 8c50efb2..79d22587 100644 --- a/playground/src/test/kotlin/com/ramcosta/composedestinations/ksp/ProcessorProviderTests.kt +++ b/playground/src/test/kotlin/com/ramcosta/composedestinations/ksp/ProcessorProviderTests.kt @@ -363,7 +363,7 @@ class ProcessorProviderTests { import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph - import com.ramcosta.samples.playgroundshared.BlogPostArgs + import com.ramcosta.playground.core.BlogPostArgs @RootNavGraph(start = true) @Destination(route = "test1") @@ -398,7 +398,7 @@ class ProcessorProviderTests { import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph - import com.ramcosta.samples.playgroundshared.WithDefaultValueArgs + import com.ramcosta.playground.core.WithDefaultValueArgs @RootNavGraph(start = true) @Destination(route = "test1") diff --git a/sample-wear/.gitignore b/sample-wear/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/sample-wear/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample-wear/build.gradle.kts b/sample-wear/build.gradle.kts new file mode 100644 index 00000000..01afaf94 --- /dev/null +++ b/sample-wear/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + id("com.android.application") + kotlin("android") + id("com.google.devtools.ksp") version libs.versions.ksp.get() +} + +tasks.withType().configureEach { + kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" +} + +android { + compileSdk = 33 + + defaultConfig { + applicationId = "com.ramcosta.destinations.sample.wear" + minSdk = 25 + targetSdk = 33 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } + + packagingOptions { + resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}") + } + + applicationVariants.all { + kotlin.sourceSets { + getByName(name) { + kotlin.srcDir("build/generated/ksp/$name/kotlin") + } + } + } +} + +dependencies { + implementation(project(mapOf("path" to ":compose-destinations-wear"))) + ksp(project(":compose-destinations-ksp")) + + implementation(libs.androidMaterial) + + implementation(libs.compose.ui) + implementation(libs.compose.material) + implementation(libs.compose.viewModel) + + implementation(libs.androidx.lifecycleRuntimeKtx) + implementation(libs.androidx.activityCompose) + + implementation(libs.wear.compose.foundation) + implementation(libs.wear.compose.material) + implementation(libs.wear.input) +} diff --git a/sample-wear/proguard-rules.pro b/sample-wear/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/sample-wear/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample-wear/src/main/AndroidManifest.xml b/sample-wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f8e66e5e --- /dev/null +++ b/sample-wear/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainActivity.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainActivity.kt new file mode 100644 index 00000000..c46bca75 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainActivity.kt @@ -0,0 +1,30 @@ +package com.ramcosta.destinations.sample.wear + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import com.ramcosta.destinations.sample.wear.core.di.DependencyContainer +import com.ramcosta.destinations.sample.wear.ui.theme.DestinationsTodoSampleTheme + +val LocalDependencyContainer = staticCompositionLocalOf { + error("No dependency container provided!") +} + +class MainActivity : ComponentActivity() { + + private val dependencyContainer by lazy { DependencyContainer(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + DestinationsTodoSampleTheme { + CompositionLocalProvider(LocalDependencyContainer provides dependencyContainer) { + SampleApp() + } + } + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainViewModel.kt new file mode 100644 index 00000000..a7e010e4 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/MainViewModel.kt @@ -0,0 +1,21 @@ +package com.ramcosta.destinations.sample.wear + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.login.data.LoginStateRepository +import kotlinx.coroutines.launch + +class MainViewModel( + private val loginStateRepository: LoginStateRepository +) : ViewModel() { + + val isLoggedInFlow = loginStateRepository.isLoggedIn + + val isLoggedIn get() = isLoggedInFlow.value + + fun login() { + viewModelScope.launch { + loginStateRepository.login() + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/SampleApp.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/SampleApp.kt new file mode 100644 index 00000000..ebcbfb65 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/SampleApp.kt @@ -0,0 +1,61 @@ +package com.ramcosta.destinations.sample.wear + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.navigation.NavHostController +import androidx.wear.compose.material.rememberSwipeToDismissBoxState +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.navigate +import com.ramcosta.composedestinations.wear.rememberWearNavHostEngine +import com.ramcosta.destinations.sample.wear.core.viewmodel.activityViewModel +import com.ramcosta.destinations.sample.wear.destinations.Destination +import com.ramcosta.destinations.sample.wear.destinations.LoginScreenDestination +import com.ramcosta.destinations.sample.wear.ui.composables.SampleScaffold + +@Composable +fun SampleApp() { + val engine = rememberWearNavHostEngine() + val navController = engine.rememberNavController() + + val vm = activityViewModel() + // 👇 this avoids a jump in the UI that would happen if we relied only on ShowLoginWhenLoggedOut + val startRoute = if (!vm.isLoggedIn) LoginScreenDestination else NavGraphs.root.startRoute + + SampleScaffold( + navController = navController, + startRoute = startRoute, + ) { + DestinationsNavHost( + engine = engine, + navController = navController, + navGraph = NavGraphs.root, + startRoute = startRoute + ) + + // Has to be called after calling DestinationsNavHost because only + // then does NavController have a graph associated that we need for + // `appCurrentDestinationAsState` method + ShowLoginWhenLoggedOut(vm, navController) + } +} + +val Destination.shouldShowScaffoldElements get() = this !is LoginScreenDestination + +@Composable +private fun ShowLoginWhenLoggedOut( + vm: MainViewModel, + navController: NavHostController +) { + val currentDestination by navController.appCurrentDestinationAsState() + val isLoggedIn by vm.isLoggedInFlow.collectAsState() + + if (!isLoggedIn && currentDestination != LoginScreenDestination) { + // everytime destination changes or logged in state we check + // if we have to show Login screen and navigate to it if so + navController.navigate(LoginScreenDestination) { + launchSingleTop = true + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountScreen.kt new file mode 100644 index 00000000..842d1719 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountScreen.kt @@ -0,0 +1,31 @@ +package com.ramcosta.destinations.sample.wear.account + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel + +@Destination +@Composable +fun AccountScreen( + vm: AccountViewModel = viewModel(), +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Button(onClick = { vm.onLogoutClick() }) { + Text("Logout", modifier = Modifier.padding(6.dp)) + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountViewModel.kt new file mode 100644 index 00000000..1e5ce08f --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/account/AccountViewModel.kt @@ -0,0 +1,17 @@ +package com.ramcosta.destinations.sample.wear.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.login.data.LoginStateRepository +import kotlinx.coroutines.launch + +class AccountViewModel( + private val loginStateRepository: LoginStateRepository +) : ViewModel() { + + fun onLogoutClick() { + viewModelScope.launch { + loginStateRepository.logout() + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/di/DependencyContainer.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/di/DependencyContainer.kt new file mode 100644 index 00000000..8258651e --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/di/DependencyContainer.kt @@ -0,0 +1,53 @@ +package com.ramcosta.destinations.sample.wear.core.di + +import androidx.lifecycle.SavedStateHandle +import com.ramcosta.destinations.sample.wear.MainActivity +import com.ramcosta.destinations.sample.wear.MainViewModel +import com.ramcosta.destinations.sample.wear.account.AccountViewModel +import com.ramcosta.destinations.sample.wear.login.data.LoginStateRepository +import com.ramcosta.destinations.sample.wear.navArgs +import com.ramcosta.destinations.sample.wear.tasks.data.StepsRepository +import com.ramcosta.destinations.sample.wear.tasks.data.TasksRepository +import com.ramcosta.destinations.sample.wear.tasks.presentation.details.StepDetailsViewModel +import com.ramcosta.destinations.sample.wear.tasks.presentation.details.StepScreenNavArgs +import com.ramcosta.destinations.sample.wear.tasks.presentation.details.TaskDetailsViewModel +import com.ramcosta.destinations.sample.wear.tasks.presentation.list.TaskListViewModel +import com.ramcosta.destinations.sample.wear.tasks.presentation.new.AddStepDialogNavArgs +import com.ramcosta.destinations.sample.wear.tasks.presentation.new.AddStepViewModel +import com.ramcosta.destinations.sample.wear.tasks.presentation.new.AddTaskViewModel + +class DependencyContainer( + val activity: MainActivity +) { + + val loginStateRepository: LoginStateRepository by lazy { LoginStateRepository() } + + val tasksRepository: TasksRepository by lazy { TasksRepository(stepsRepository) } + + val stepsRepository: StepsRepository by lazy { StepsRepository() } + + @Suppress("UNCHECKED_CAST") + fun createViewModel(modelClass: Class, handle: SavedStateHandle): T { + return when (modelClass) { + MainViewModel::class.java -> MainViewModel(loginStateRepository) + AccountViewModel::class.java -> AccountViewModel(loginStateRepository) + TaskListViewModel::class.java -> TaskListViewModel(tasksRepository, stepsRepository) + AddTaskViewModel::class.java -> AddTaskViewModel(tasksRepository) + AddStepViewModel::class.java -> AddStepViewModel( + handle.navArgs().taskId, + stepsRepository + ) + TaskDetailsViewModel::class.java -> TaskDetailsViewModel( + handle, + tasksRepository, + stepsRepository + ) + StepDetailsViewModel::class.java -> StepDetailsViewModel( + handle.navArgs().stepId, + tasksRepository, + stepsRepository + ) + else -> throw RuntimeException("Unknown view model $modelClass") + } as T + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/viewmodel/ViewModelUtils.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/viewmodel/ViewModelUtils.kt new file mode 100644 index 00000000..62528ecd --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/core/viewmodel/ViewModelUtils.kt @@ -0,0 +1,65 @@ +package com.ramcosta.destinations.sample.wear.core.viewmodel + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation.NavBackStackEntry +import androidx.savedstate.SavedStateRegistryOwner +import com.ramcosta.destinations.sample.wear.LocalDependencyContainer +import com.ramcosta.destinations.sample.wear.core.di.DependencyContainer + +@Composable +inline fun viewModel( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + savedStateRegistryOwner: SavedStateRegistryOwner = LocalSavedStateRegistryOwner.current +): VM { + return androidx.lifecycle.viewmodel.compose.viewModel( + viewModelStoreOwner = viewModelStoreOwner, + factory = ViewModelFactory( + owner = savedStateRegistryOwner, + defaultArgs = (savedStateRegistryOwner as? NavBackStackEntry)?.arguments, + dependencyContainer = LocalDependencyContainer.current, + ) + ) +} + +@Composable +inline fun activityViewModel(): VM { + val activity = LocalDependencyContainer.current.activity + + return androidx.lifecycle.viewmodel.compose.viewModel( + VM::class.java, + activity, + null, + factory = ViewModelFactory( + owner = activity, + defaultArgs = null, + dependencyContainer = LocalDependencyContainer.current, + ) + ) +} + +class ViewModelFactory( + owner: SavedStateRegistryOwner, + defaultArgs: Bundle?, + private val dependencyContainer: DependencyContainer +) : AbstractSavedStateViewModelFactory( + owner, + defaultArgs +) { + + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return dependencyContainer.createViewModel(modelClass, handle) + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/LoginScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/LoginScreen.kt new file mode 100644 index 00000000..6ff6d8f5 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/LoginScreen.kt @@ -0,0 +1,68 @@ +package com.ramcosta.destinations.sample.wear.login + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.destinations.sample.wear.MainViewModel +import com.ramcosta.destinations.sample.wear.NavGraphs +import com.ramcosta.destinations.sample.wear.core.viewmodel.activityViewModel +import com.ramcosta.destinations.sample.wear.destinations.DirectionDestination +import com.ramcosta.destinations.sample.wear.destinations.LoginScreenDestination +import com.ramcosta.destinations.sample.wear.startAppDestination + +@Destination +@Composable +fun LoginScreen( + vm: MainViewModel = activityViewModel(), + navigator: DestinationsNavigator +) { + BackHandler(true) { /* We want to disable back clicks */ } + + val isLoggedIn by vm.isLoggedInFlow.collectAsState() + val hasNavigatedUp = remember { mutableStateOf(false) } + + if (isLoggedIn && !hasNavigatedUp.value) { + hasNavigatedUp.value = true // avoids double navigation + + if (!navigator.navigateUp()) { + // Sometimes we are starting on LoginScreen (to avoid UI jumps) + // In those cases, navigateUp fails, so we just navigate to the registered start destination + navigator.navigate(NavGraphs.root.startAppDestination as DirectionDestination) { + popUpTo(LoginScreenDestination) { + inclusive = true + } + } + } + } + + LoginScreenContent { vm.login() } +} + +@Composable +private fun LoginScreenContent(onLoginClick: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text("WORK IN PROGRESS") + + Spacer(Modifier.height(32.dp)) + + Chip(onClick = onLoginClick, label = { + Text("Login") + }) + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/data/LoginStateRepository.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/data/LoginStateRepository.kt new file mode 100644 index 00000000..e55ba136 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/login/data/LoginStateRepository.kt @@ -0,0 +1,21 @@ +package com.ramcosta.destinations.sample.wear.login.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +class LoginStateRepository { + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn = _isLoggedIn.asStateFlow() + + suspend fun login() = withContext(Dispatchers.IO) { + _isLoggedIn.update { true } + } + + suspend fun logout() = withContext(Dispatchers.IO) { + _isLoggedIn.update { false } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/settings/SettingScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/settings/SettingScreen.kt new file mode 100644 index 00000000..0496ae69 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/settings/SettingScreen.kt @@ -0,0 +1,20 @@ +package com.ramcosta.destinations.sample.wear.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.wear.compose.material.Text +import com.ramcosta.composedestinations.annotation.Destination + +@Destination +@Composable +fun SettingsScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Settings") + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/StepsRepository.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/StepsRepository.kt new file mode 100644 index 00000000..89e2393e --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/StepsRepository.kt @@ -0,0 +1,55 @@ +package com.ramcosta.destinations.sample.wear.tasks.data + +import com.ramcosta.destinations.sample.wear.tasks.domain.Step +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger + +class StepsRepository { + + private val nextId = AtomicInteger(0) + + private val _steps = MutableStateFlow>(emptyList()) + val steps = _steps.asStateFlow() + + suspend fun removeStep(step: Step) = withContext(Dispatchers.IO) { + _steps.update { it - step } + } + + fun stepsByTask(taskId: Int): Flow> { + return _steps.map { + it.filter { it.taskId == taskId } + } + } + + suspend fun removeStepsForTask(task: Task) = withContext(Dispatchers.IO) { + _steps.update { + it.toMutableList().apply { removeAll { it.taskId == task.id } } + } + } + + suspend fun updateStep(step: Step) = withContext(Dispatchers.IO) { + _steps.update { + val idx = it.indexOfFirst { it.id == step.id } + if (idx != -1) { + it.toMutableList().apply { + removeAt(idx) + add(idx, step) + } + } else { + it + } + } + } + + suspend fun addNewStep(step: Step) = withContext(Dispatchers.IO) { + val stepWithRightId = step.copy(id = nextId.getAndAdd(1)) + _steps.update { it + stepWithRightId } + } + + fun stepForId(stepId: Int): Flow { + return _steps.map { it.first { it.id == stepId } } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/TasksRepository.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/TasksRepository.kt new file mode 100644 index 00000000..be59128f --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/data/TasksRepository.kt @@ -0,0 +1,53 @@ +package com.ramcosta.destinations.sample.wear.tasks.data + +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext + +class TasksRepository( + private val stepsRepository: StepsRepository +) { + + private val _tasks: MutableStateFlow> = MutableStateFlow>(mutableListOf()).apply { + value = mutableListOf( + Task("Task #1", "Description #1", false, 1), + Task("Task #2", "Description #2", false, 2), + Task("Task #3", "Description #3", false, 3), + Task("Task #4", "Description #4", false, 4), + Task("Task #5", "Description #5", false, 5), + Task("Task #6", "Description #6", false, 6), + ) + } + val tasks: StateFlow> = _tasks.asStateFlow() + + fun taskById(id: Int): Flow = _tasks.map { it.firstOrNull { it.id == id } } + + suspend fun addNewTask(task: Task) = withContext(Dispatchers.IO) { + _tasks.update { + it.toMutableList().apply { + add(task.copy(id = (it.lastOrNull()?.id ?: -1) + 1)) + } + } + } + + suspend fun updateTask(task: Task) = withContext(Dispatchers.IO) { + _tasks.update { + it.toMutableList().apply { + val idx = indexOfFirst { it.id == task.id } + if (idx != -1) { + removeAt(idx) + add(idx, task) + } + } + } + } + + suspend fun deleteTask(task: Task) = withContext(Dispatchers.IO) { + _tasks.update { + it.toMutableList().apply { remove(task) } + } + + stepsRepository.removeStepsForTask(task) + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Step.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Step.kt new file mode 100644 index 00000000..10af2605 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Step.kt @@ -0,0 +1,8 @@ +package com.ramcosta.destinations.sample.wear.tasks.domain + +data class Step( + val taskId: Int, + val title: String, + val completed: Boolean, + val id: Int = 0, +) diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Task.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Task.kt new file mode 100644 index 00000000..5d8657e2 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/domain/Task.kt @@ -0,0 +1,8 @@ +package com.ramcosta.destinations.sample.wear.tasks.domain + +data class Task( + val title: String, + val description: String, + val completed: Boolean, + val id: Int = 0, +) diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepDetailsViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepDetailsViewModel.kt new file mode 100644 index 00000000..d25ea8aa --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepDetailsViewModel.kt @@ -0,0 +1,35 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.tasks.data.StepsRepository +import com.ramcosta.destinations.sample.wear.tasks.data.TasksRepository +import com.ramcosta.destinations.sample.wear.tasks.domain.Step +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class StepDetailsViewModel( + private val stepId: Int, + private val tasksRepository: TasksRepository, + private val stepsRepository: StepsRepository +): ViewModel() { + + val step: StateFlow = stepsRepository.stepForId(stepId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + @OptIn(FlowPreview::class) + val task: StateFlow = stepsRepository.stepForId(stepId) + .flatMapConcat { + tasksRepository.taskById(it.taskId) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun onStepCheckedChange(checked: Boolean) { + val value = step.value ?: return + viewModelScope.launch { + stepsRepository.updateStep(value.copy(completed = checked)) + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreen.kt new file mode 100644 index 00000000..00ec3eb6 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreen.kt @@ -0,0 +1,49 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +import androidx.compose.foundation.layout.* +import androidx.wear.compose.material.Checkbox +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel + +@Destination(navArgsDelegate = StepScreenNavArgs::class) +@Composable +fun StepScreen( + viewModel: StepDetailsViewModel = viewModel() +) { + val step = viewModel.step.collectAsState().value + + if (step == null) { + CircularProgressIndicator() + return + } + + Box( + modifier = Modifier.fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Completed") + + Checkbox( + checked = step.completed, + onCheckedChange = viewModel::onStepCheckedChange + ) + } + + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreenNavArgs.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreenNavArgs.kt new file mode 100644 index 00000000..2258148e --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/StepScreenNavArgs.kt @@ -0,0 +1,5 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +data class StepScreenNavArgs( + val stepId: Int +) \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskDetailsViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskDetailsViewModel.kt new file mode 100644 index 00000000..d06f94e9 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskDetailsViewModel.kt @@ -0,0 +1,42 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.navArgs +import com.ramcosta.destinations.sample.wear.tasks.data.StepsRepository +import com.ramcosta.destinations.sample.wear.tasks.data.TasksRepository +import com.ramcosta.destinations.sample.wear.tasks.domain.Step +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class TaskDetailsViewModel( + savedStateHandle: SavedStateHandle, + private val tasksRepository: TasksRepository, + private val stepsRepository: StepsRepository +) : ViewModel() { + + private val navArgs: TaskScreenNavArgs = savedStateHandle.navArgs() + + val task: StateFlow = tasksRepository.taskById(navArgs.taskId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val steps: StateFlow> = stepsRepository.stepsByTask(navArgs.taskId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun onStepCheckedChanged(step: Step, checked: Boolean) { + viewModelScope.launch { + stepsRepository.updateStep(step.copy(completed = checked)) + } + } + + fun onTaskCheckedChange(checked: Boolean) { + val task = task.value ?: return + viewModelScope.launch { + tasksRepository.updateTask(task.copy(completed = checked)) + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreen.kt new file mode 100644 index 00000000..a87c07ef --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreen.kt @@ -0,0 +1,105 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material.* +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel +import com.ramcosta.destinations.sample.wear.destinations.AddStepScreenDestination +import com.ramcosta.destinations.sample.wear.destinations.StepScreenDestination +import com.ramcosta.destinations.sample.wear.tasks.domain.Step + +@Destination(navArgsDelegate = TaskScreenNavArgs::class) +@Composable +fun TaskScreen( + navArgs: TaskScreenNavArgs, + navigator: DestinationsNavigator, + viewModel: TaskDetailsViewModel = viewModel() +) { + val task = viewModel.task.collectAsState().value + + if (task == null) { + CircularProgressIndicator() + return + } + + val steps = viewModel.steps.collectAsState().value + + ScalingLazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + Button( + onClick = { navigator.navigate(AddStepScreenDestination(navArgs.taskId)) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "add step button" + ) + } + } + + item { + ToggleChip( + modifier = Modifier.fillMaxWidth(), + checked = task.completed, + onCheckedChange = viewModel::onTaskCheckedChange, + label = { Text("Completed:") }, + toggleControl = { + Icon( + imageVector = ToggleChipDefaults.checkboxIcon(checked = task.completed), + contentDescription = if (task.completed) "On" else "Off" + ) + } + ) + } + + item { + ListHeader { + Text("Steps") + } + } + + if (steps.isEmpty()) { + item { + Text("None", style = MaterialTheme.typography.body2) + } + } + + items(steps) { step -> + StepItem( + step = step, + onStepClicked = { + navigator.navigate(StepScreenDestination(step.id)) + }, + onCheckedChange = { viewModel.onStepCheckedChanged(step, it) } + ) + } + } +} + +@Composable +fun StepItem( + step: Step, + onStepClicked: () -> Unit, + onCheckedChange: (Boolean) -> Unit +) { + SplitToggleChip( + modifier = Modifier.fillMaxWidth(), + onClick = onStepClicked, + checked = step.completed, + onCheckedChange = onCheckedChange, + label = { Text(step.title) }, + toggleControl = { + Icon( + imageVector = ToggleChipDefaults.checkboxIcon(checked = step.completed), + contentDescription = if (step.completed) "On" else "Off" + ) + } + ) +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreenNavArgs.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreenNavArgs.kt new file mode 100644 index 00000000..82a7ff43 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/details/TaskScreenNavArgs.kt @@ -0,0 +1,5 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.details + +data class TaskScreenNavArgs( + val taskId: Int +) \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskItem.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskItem.kt new file mode 100644 index 00000000..f91aaf2c --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskItem.kt @@ -0,0 +1,37 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.list + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.SplitToggleChip +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.ToggleChipDefaults + +@Composable +fun TaskItem( + task: TaskUiItem, + onCheckedChange: (Boolean) -> Unit, + onTaskClicked: () -> Unit +) { + SplitToggleChip( + modifier = Modifier.fillMaxWidth(), + onClick = onTaskClicked, + checked = task.task.completed, + onCheckedChange = onCheckedChange, + label = { + Text(task.task.title) + }, + toggleControl = { + Icon( + imageVector = ToggleChipDefaults.checkboxIcon(checked = task.task.completed), + contentDescription = if (task.task.completed) "On" else "Off" + ) + }, + secondaryLabel = if (task.steps.isNotEmpty()) { + { + Text("${task.steps.filter { it.completed }.size}/${task.steps.size}") + } + } else null, + ) +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListScreen.kt new file mode 100644 index 00000000..14ef53bc --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListScreen.kt @@ -0,0 +1,66 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.list + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.ScalingLazyColumn +import androidx.wear.compose.material.items +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel +import com.ramcosta.destinations.sample.wear.destinations.AccountScreenDestination +import com.ramcosta.destinations.sample.wear.destinations.AddTaskScreenDestination +import com.ramcosta.destinations.sample.wear.destinations.SettingsScreenDestination +import com.ramcosta.destinations.sample.wear.destinations.TaskScreenDestination + +@RootNavGraph(start = true) +@Destination +@Composable +fun TaskListScreen( + navigator: DestinationsNavigator, + viewModel: TaskListViewModel = viewModel() +) { + val tasks by viewModel.tasks.collectAsState() + + ScalingLazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + Row { + Button( + onClick = { navigator.navigate(AddTaskScreenDestination) }) { + Icon(imageVector = Icons.Default.Add, contentDescription = "add task button") + } + + Button(onClick = { navigator.navigate(AccountScreenDestination) }) { + Icon(imageVector = Icons.Default.AccountCircle, contentDescription = "Account") + } + + Button(onClick = { navigator.navigate(SettingsScreenDestination) }) { + Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings") + } + } + } + items(tasks) { item -> + TaskItem( + task = item, + onCheckedChange = { + viewModel.onCheckboxChecked(item.task, it) + }, + onTaskClicked = { + navigator.navigate(TaskScreenDestination(item.task.id)) + } + ) + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListViewModel.kt new file mode 100644 index 00000000..0dd2dbfd --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskListViewModel.kt @@ -0,0 +1,29 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.tasks.data.StepsRepository +import com.ramcosta.destinations.sample.wear.tasks.data.TasksRepository +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class TaskListViewModel( + private val tasksRepository: TasksRepository, + private val stepsRepository: StepsRepository +) : ViewModel() { + + val tasks: StateFlow> = tasksRepository.tasks + .combine(stepsRepository.steps) { tasks, steps -> + tasks.map { task -> + TaskUiItem(task, steps.filter { it.taskId == task.id }) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun onCheckboxChecked(task: Task, checked: Boolean) { + viewModelScope.launch { + tasksRepository.updateTask(task.copy(completed = checked)) + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskUiItem.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskUiItem.kt new file mode 100644 index 00000000..03cd4446 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/list/TaskUiItem.kt @@ -0,0 +1,9 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.list + +import com.ramcosta.destinations.sample.wear.tasks.domain.Step +import com.ramcosta.destinations.sample.wear.tasks.domain.Task + +data class TaskUiItem( + val task: Task, + val steps: List +) \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepScreen.kt new file mode 100644 index 00000000..511de27e --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepScreen.kt @@ -0,0 +1,30 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.new + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel +import com.ramcosta.destinations.sample.wear.ui.composables.TitleConfirmDialog + +data class AddStepDialogNavArgs( + val taskId: Int +) + +@Destination(navArgsDelegate = AddStepDialogNavArgs::class) +@Composable +fun AddStepScreen( + navigator: DestinationsNavigator, + viewModel: AddStepViewModel = viewModel() +) { + TitleConfirmDialog( + type = "step", //use string resources in a real app ofc :) + title = viewModel.title.collectAsState().value, + onTitleChange = viewModel::onTitleChange, + onConfirm = { + viewModel.onConfirmClicked() + navigator.popBackStack() + }, + onDismissRequest = { navigator.popBackStack() } + ) +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepViewModel.kt new file mode 100644 index 00000000..17d65cc8 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddStepViewModel.kt @@ -0,0 +1,29 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.new + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.tasks.data.StepsRepository +import com.ramcosta.destinations.sample.wear.tasks.domain.Step +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AddStepViewModel( + private val taskId: Int, + private val stepsRepository: StepsRepository +) : ViewModel() { + + private val _title = MutableStateFlow("") + val title = _title.asStateFlow() + + fun onTitleChange(newTitle: String) { + _title.update { newTitle } + } + + fun onConfirmClicked() { + viewModelScope.launch { + stepsRepository.addNewStep(Step(taskId, _title.value, false)) + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskScreen.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskScreen.kt new file mode 100644 index 00000000..421fdb31 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskScreen.kt @@ -0,0 +1,26 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.new + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.destinations.sample.wear.core.viewmodel.viewModel +import com.ramcosta.destinations.sample.wear.ui.composables.TitleConfirmDialog + +@Destination +@Composable +fun AddTaskScreen( + navigator: DestinationsNavigator, + viewModel: AddTaskViewModel = viewModel() +) { + TitleConfirmDialog( + type = "task", //use string resources in a real app ofc :) + title = viewModel.title.collectAsState().value, + onTitleChange = viewModel::onTitleChange, + onConfirm = { + viewModel.onConfirmClicked() + navigator.popBackStack() + }, + onDismissRequest = { navigator.popBackStack() } + ) +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskViewModel.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskViewModel.kt new file mode 100644 index 00000000..0acb791b --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/tasks/presentation/new/AddTaskViewModel.kt @@ -0,0 +1,28 @@ +package com.ramcosta.destinations.sample.wear.tasks.presentation.new + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.destinations.sample.wear.tasks.data.TasksRepository +import com.ramcosta.destinations.sample.wear.tasks.domain.Task +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AddTaskViewModel( + private val tasksRepository: TasksRepository +) : ViewModel() { + + private val _title = MutableStateFlow("") + val title = _title.asStateFlow() + + fun onTitleChange(newTitle: String) { + _title.update { newTitle } + } + + fun onConfirmClicked() { + viewModelScope.launch { + tasksRepository.addNewTask(Task(_title.value, "", false)) + } + } +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/SampleScaffold.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/SampleScaffold.kt new file mode 100644 index 00000000..ffb2e2ef --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/SampleScaffold.kt @@ -0,0 +1,29 @@ +package com.ramcosta.destinations.sample.wear.ui.composables + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.TimeText +import com.ramcosta.composedestinations.spec.Route +import com.ramcosta.destinations.sample.wear.appCurrentDestinationAsState +import com.ramcosta.destinations.sample.wear.shouldShowScaffoldElements +import com.ramcosta.destinations.sample.wear.startAppDestination + +@Composable +fun SampleScaffold( + startRoute: Route, + navController: NavHostController, + content: @Composable () -> Unit, +) { + val destination = navController.appCurrentDestinationAsState().value + ?: startRoute.startAppDestination + + Scaffold( + timeText = { + if (destination.shouldShowScaffoldElements) { + TimeText() + } + }, + content = content + ) +} diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/TitleConfirmDialog.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/TitleConfirmDialog.kt new file mode 100644 index 00000000..77a90252 --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/composables/TitleConfirmDialog.kt @@ -0,0 +1,87 @@ +package com.ramcosta.destinations.sample.wear.ui.composables + +import android.app.RemoteInput +import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.dialog.Dialog +import androidx.wear.input.RemoteInputIntentHelper + +@Composable +fun TitleConfirmDialog( + type: String, + title: String, + onTitleChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit +) { + Dialog(showDialog = true, onDismissRequest = onDismissRequest) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround + ) { + var textForUserInput by remember { mutableStateOf("") } + val inputTextKey = "input_text" + + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + it.data?.let { data -> + val results: Bundle = RemoteInput.getResultsFromIntent(data) + val newInputText: CharSequence? = results.getCharSequence(inputTextKey) + textForUserInput = newInputText as String + } + } + + val intent = remember { + RemoteInputIntentHelper.createActionRemoteInputIntent().apply { + RemoteInputIntentHelper.putRemoteInputsExtra( + this, listOf( + RemoteInput.Builder(inputTextKey) + .setLabel("${type.replaceFirstChar { it.uppercase() }} title") + .build() + ) + ) + } + } + + Text("Add a new $type:") + + Text( + modifier = Modifier.fillMaxWidth(0.8f).border(1.dp, Color.White).clickable { + launcher.launch(intent) + }, + text = textForUserInput, + ) + + Chip( + onClick = { + onTitleChange(textForUserInput) + onConfirm() + }, + label = { + Text("Confirm") + } + ) + } + } +} \ No newline at end of file diff --git a/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/theme/Theme.kt b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/theme/Theme.kt new file mode 100644 index 00000000..42ec02aa --- /dev/null +++ b/sample-wear/src/main/java/com/ramcosta/destinations/sample/wear/ui/theme/Theme.kt @@ -0,0 +1,13 @@ +package com.ramcosta.destinations.sample.wear.ui.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun DestinationsTodoSampleTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + content = content + ) +} \ No newline at end of file diff --git a/sample-wear/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample-wear/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/sample-wear/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample-wear/src/main/res/drawable/ic_launcher_background.xml b/sample-wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/sample-wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/sample-wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample-wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample-wear/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/sample-wear/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/sample-wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample-wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/sample-wear/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample-wear/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/sample-wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample-wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/sample-wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/sample-wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/sample-wear/src/main/res/values/strings.xml b/sample-wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..7f8070a1 --- /dev/null +++ b/sample-wear/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + sample + Settings + Account + Tasks + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f1067b2..938c19eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,8 @@ include(":compose-destinations") include(":compose-destinations-ksp") include(":compose-destinations-codegen") include(":compose-destinations-animations") +include(":compose-destinations-wear") include(":sample") +include(":sample-wear") include(":playground") -include(":playground-shared") +include(":playground-core")