diff --git a/sample-compose/app/build.gradle.kts b/sample-compose/app/build.gradle.kts index c135b8fbb..38f9fa219 100644 --- a/sample-compose/app/build.gradle.kts +++ b/sample-compose/app/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.kover) @@ -137,6 +137,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) debugImplementation(libs.chucker) releaseImplementation(libs.chucker.noOp) diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt new file mode 100644 index 000000000..01a7cd3d1 --- /dev/null +++ b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.sample.compose.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.sample.compose.navigation.Navigator +import kotlin.reflect.KClass + +class FakeNavigator : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf() + + override fun goTo(destination: Any) { + backStack.add(destination) + } + + override fun goBack() { + backStack.removeLastOrNull() + } + + override fun goBackToLast(destinationClass: KClass<*>) { + val index = backStack.indexOfLast { + destinationClass.isInstance(it) + } + + if (index in backStack.indices) { + backStack.removeRange(index + 1, backStack.size) + } + } + + fun addToBackStack(listOfPastDestinations: List) { + backStack.addAll(listOfPastDestinations) + } + + fun currentScreen(): Any? = backStack.lastOrNull() + + fun currentScreenClass(): KClass<*>? = backStack.lastOrNull()?.let { it::class } +} diff --git a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt deleted file mode 100644 index b090086e8..000000000 --- a/sample-compose/app/src/androidTest/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import androidx.activity.compose.setContent -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.rule.GrantPermissionRule -import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase -import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.test.TestDispatchersProvider -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.screens.MainActivity -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.theme.ComposeTheme -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -class HomeScreenTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - /** - * More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/ - */ - @get:Rule - val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.CAMERA - ) - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() - - private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null - - @Before - fun setUp() { - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) - - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - TestDispatchersProvider - ) - } - - @Test - fun when_entering_the_Home_screen__it_shows_UI_correctly() = initComposable { - onNodeWithText("Home").assertIsDisplayed() - } - - @Test - fun when_loading_list_item_successfully__it_shows_the_list_item_correctly() = initComposable { - onNodeWithText("1").assertIsDisplayed() - onNodeWithText("2").assertIsDisplayed() - onNodeWithText("3").assertIsDisplayed() - } - - @Test - fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable { - onNodeWithText("1").performClick() - - assertEquals(expectedDestination, MainDestination.Second) - } - - private fun initComposable( - testBody: AndroidComposeTestRule, MainActivity>.() -> Unit - ) { - composeRule.activity.setContent { - ComposeTheme { - HomeScreen( - viewModel = viewModel, - navigator = { destination -> expectedDestination = destination } - ) - } - } - testBody(composeRule) - } -} diff --git a/sample-compose/app/src/main/AndroidManifest.xml b/sample-compose/app/src/main/AndroidManifest.xml index ae40d2156..bd664f5b2 100644 --- a/sample-compose/app/src/main/AndroidManifest.xml +++ b/sample-compose/app/src/main/AndroidManifest.xml @@ -27,16 +27,6 @@ - - - - - - - - - - diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt index e68084012..f6a759a61 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/di/modules/main/MainActivityModule.kt @@ -1,9 +1,122 @@ package co.nimblehq.sample.compose.di.modules.main +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import co.nimblehq.sample.compose.navigation.EntryProviderInstaller +import co.nimblehq.sample.compose.navigation.Navigator +import co.nimblehq.sample.compose.navigation.NavigatorImpl +import co.nimblehq.sample.compose.ui.common.PATH_BASE +import co.nimblehq.sample.compose.ui.common.PATH_SEARCH +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen +import co.nimblehq.sample.compose.ui.screens.details.DetailsViewModel +import co.nimblehq.sample.compose.ui.screens.list.ListScreen +import co.nimblehq.sample.compose.ui.screens.login.LoginOrRegisterScreen +import co.nimblehq.sample.compose.ui.screens.login.LoginScreen +import co.nimblehq.sample.compose.ui.screens.search.SearchScreen +import co.nimblehq.sample.compose.util.LocalResultEventBus +import co.nimblehq.sample.compose.util.ResultEffect import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.multibindings.IntoSet @Module -@InstallIn(ActivityComponent::class) -class MainActivityModule +@InstallIn(ActivityRetainedComponent::class) +object MainActivityModule { + + @Provides + @ActivityRetainedScoped + fun provideNavigator(): Navigator = NavigatorImpl(startDestination = ListScreen) + + @Suppress("LongMethod") + @IntoSet + @Provides + fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = + { + entry { + ListScreen( + viewModel = hiltViewModel(), + onClickSearch = { navigator.goTo(SearchScreen) }, + onItemClick = { uiModel -> + navigator.goTo(DetailsScreen.Details(uiModel.id.toIntOrNull() ?: 1)) + } + ) + } + + entry { + val context = LocalContext.current + SearchScreen( + onClickCreateDeeplink = { username -> + val intent = Intent( + Intent.ACTION_VIEW, + "$PATH_BASE/$PATH_SEARCH?${DetailsScreen.Search::username.name}=${Uri.encode(username)}".toUri() + ) + context.startActivity(intent) + }, + onClickBack = navigator::goBack + ) + } + + entry { key -> + val viewModel = hiltViewModel( + // Note: We need a new ViewModel for every new DetailsScreen instance. Usually + // we would need to supply a `key` String that is unique to the + // instance, however, the ViewModelStoreNavEntryDecorator (supplied + // above) does this for us, using `NavEntry.contentKey` to uniquely + // identify the viewModel. + // + // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator() + // if you want a new ViewModel for each new navigation key instance. + creationCallback = { factory -> factory.create(key) } + ) + val eventBus = LocalResultEventBus.current + + ResultEffect { username -> + viewModel.changeUsername(username) + eventBus.removeResult() + } + + DetailsScreen( + viewModel = viewModel, + navigateToLoginOrRegister = { navigator.goTo(LoginOrRegisterScreen) }, + onClickBack = navigator::goBack + ) + } + + entry { key -> + val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(key) } + ) + + DetailsScreen( + viewModel = viewModel, + navigateToLoginOrRegister = { navigator.goTo(LoginOrRegisterScreen) }, + onClickBack = navigator::goBack + ) + } + + entry { + LoginOrRegisterScreen( + navigateToLogin = { navigator.goTo(LoginScreen) }, + navigateToRegister = { /* NO-OP */ }, + onClickBack = navigator::goBack + ) + } + + entry { + val eventBus = LocalResultEventBus.current + LoginScreen( + navigateToDetails = { username -> + eventBus.sendResult(result = username) + navigator.goBackToLast(DetailsScreen::class) + }, + onClickBack = navigator::goBack + ) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt new file mode 100644 index 000000000..60be1f0eb --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/ComponentActivityExt.kt @@ -0,0 +1,14 @@ +package co.nimblehq.sample.compose.extensions + +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge + +fun ComponentActivity.setEdgeToEdgeConfig() { + enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Force the 3-button navigation bar to be transparent + // See: https://developer.android.com/develop/ui/views/layout/edge-to-edge#create-transparent + window.isNavigationBarContrastEnforced = false + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt deleted file mode 100644 index 61fe3a7d5..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/extensions/SavedStateHandleExt.kt +++ /dev/null @@ -1,11 +0,0 @@ -package co.nimblehq.sample.compose.extensions - -import androidx.lifecycle.SavedStateHandle - -fun SavedStateHandle.getThenRemove(key: String): T? { - return if (contains(key)) { - val value = get(key) - remove(key) - value - } else null -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt new file mode 100644 index 000000000..5143029de --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/Navigator.kt @@ -0,0 +1,18 @@ +package co.nimblehq.sample.compose.navigation + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.EntryProviderScope +import kotlin.reflect.KClass + +typealias EntryProviderInstaller = EntryProviderScope.() -> Unit + +interface Navigator { + + val backStack: SnapshotStateList + + fun goTo(destination: Any) + + fun goBack() + + fun goBackToLast(destinationClass: KClass<*>) +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt new file mode 100644 index 000000000..5765fcc76 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/navigation/NavigatorImpl.kt @@ -0,0 +1,29 @@ +package co.nimblehq.sample.compose.navigation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlin.reflect.KClass + +@ActivityRetainedScoped +class NavigatorImpl(startDestination: Any) : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf(startDestination) + + override fun goTo(destination: Any) { + backStack.add(destination) + } + + override fun goBack() { + backStack.removeLastOrNull() + } + + override fun goBackToLast(destinationClass: KClass<*>) { + val index = backStack.indexOfLast { + destinationClass.isInstance(it) + } + + if (index in backStack.indices) { + backStack.removeRange(index + 1, backStack.size) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt deleted file mode 100644 index 325e9c7cd..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppDestination.kt +++ /dev/null @@ -1,10 +0,0 @@ -package co.nimblehq.sample.compose.ui - -import co.nimblehq.sample.compose.ui.base.BaseDestination - -sealed class AppDestination { - - object RootNavGraph : BaseDestination("rootNavGraph") - - object MainNavGraph : BaseDestination("mainNavGraph") -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt deleted file mode 100644 index 4782b215d..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/AppNavGraph.kt +++ /dev/null @@ -1,63 +0,0 @@ -package co.nimblehq.sample.compose.ui - -import androidx.compose.runtime.Composable -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.screens.main.mainNavGraph - -@Composable -fun AppNavGraph( - navController: NavHostController, -) { - NavHost( - navController = navController, - route = AppDestination.RootNavGraph.route, - startDestination = AppDestination.MainNavGraph.destination - ) { - mainNavGraph(navController = navController) - } -} - -fun NavGraphBuilder.composable( - destination: BaseDestination, - content: @Composable (NavBackStackEntry) -> Unit, -) { - composable( - route = destination.route, - arguments = destination.arguments, - deepLinks = destination.deepLinks.map { - navDeepLink { - uriPattern = it - } - }, - content = content - ) -} - -/** - * Navigate to provided [BaseDestination] with a Pair of key value String and Data [parcel] - * Caution to use this method. This method use savedStateHandle to store the Parcelable data. - * When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data. - * eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully. - */ -fun NavHostController.navigate(destination: BaseDestination, parcel: Pair? = null) { - when (destination) { - is BaseDestination.Up -> { - destination.results.forEach { (key, value) -> - previousBackStackEntry?.savedStateHandle?.set(key, value) - } - navigateUp() - } - else -> { - parcel?.let { (key, value) -> - currentBackStackEntry?.savedStateHandle?.set(key, value) - } - navigate(route = destination.destination) - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt deleted file mode 100644 index 03af800b4..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseDestination.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.nimblehq.sample.compose.ui.base - -import androidx.navigation.NamedNavArgument - -const val KeyResultOk = "keyResultOk" - -abstract class BaseDestination(val route: String = "") { - - open val arguments: List = emptyList() - - open val deepLinks: List = listOf( - "https://android.nimblehq.co/$route", - "android://$route", - ) - - open var destination: String = route - - open var parcelableArgument: Pair = "" to null - - data class Up(val results: HashMap = hashMapOf()) : BaseDestination() { - - fun addResult(key: String, value: Any) = apply { - results[key] = value - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt index e2255c0e3..44a01523b 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseScreen.kt @@ -3,7 +3,7 @@ package co.nimblehq.sample.compose.ui.base import androidx.compose.runtime.Composable import androidx.compose.ui.res.colorResource import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.util.setStatusBarColor +import co.nimblehq.sample.compose.util.StatusBarColor @Composable fun BaseScreen( @@ -11,7 +11,7 @@ fun BaseScreen( content: @Composable () -> Unit, ) { if (isDarkStatusBarIcons != null) { - setStatusBarColor( + StatusBarColor( color = colorResource(id = R.color.statusBarColor), darkIcons = isDarkStatusBarIcons, ) diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt index ad7649708..208a97c61 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/base/BaseViewModel.kt @@ -18,7 +18,7 @@ abstract class BaseViewModel : ViewModel() { protected val _error = MutableSharedFlow() val error = _error.asSharedFlow() - protected val _navigator = MutableSharedFlow() + protected val _navigator = MutableSharedFlow() val navigator = _navigator.asSharedFlow() /** diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt index 884dbee78..695da0f47 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/AppBar.kt @@ -1,7 +1,12 @@ package co.nimblehq.sample.compose.ui.common import androidx.annotation.StringRes +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -18,10 +23,23 @@ import co.nimblehq.sample.compose.ui.theme.ComposeTheme fun AppBar( @StringRes title: Int, modifier: Modifier = Modifier, + onClickBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( modifier = modifier, title = { Text(text = stringResource(title)) }, + navigationIcon = { + if (onClickBack != null) { + IconButton(onClick = onClickBack) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + }, + actions = actions, colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = AppTheme.colors.topAppBarBackground ) @@ -31,5 +49,5 @@ fun AppBar( @Preview(showBackground = true) @Composable private fun AppBarPreview() { - ComposeTheme { AppBar(R.string.home_title_bar) } + ComposeTheme { AppBar(R.string.list_title) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt new file mode 100644 index 000000000..023dbd4bb --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/common/Constants.kt @@ -0,0 +1,8 @@ +package co.nimblehq.sample.compose.ui.common + +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen + +internal const val PATH_BASE = "android://android.nimblehq.co" +internal const val PATH_SEARCH = "users/search" +internal val URL_SEARCH = + "$PATH_BASE/$PATH_SEARCH?${DetailsScreen.Search::username.name}={${DetailsScreen.Search::username.name}}" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt index b105af0f3..9b797fc09 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/models/UiModel.kt @@ -1,14 +1,13 @@ package co.nimblehq.sample.compose.ui.models -import android.os.Parcelable import co.nimblehq.sample.compose.domain.models.Model -import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable -@Parcelize +@Serializable data class UiModel( val id: String, val username: String, -) : Parcelable +) fun Model.toUiModel() = UiModel( id.toString(), diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt index 5c262262b..5d718f932 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/MainActivity.kt @@ -1,22 +1,124 @@ package co.nimblehq.sample.compose.ui.screens +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.navigation.compose.rememberNavController -import co.nimblehq.sample.compose.ui.AppNavGraph +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import co.nimblehq.sample.compose.extensions.setEdgeToEdgeConfig +import co.nimblehq.sample.compose.navigation.EntryProviderInstaller +import co.nimblehq.sample.compose.navigation.Navigator +import co.nimblehq.sample.compose.ui.common.URL_SEARCH +import co.nimblehq.sample.compose.ui.screens.details.DetailsScreen import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import co.nimblehq.sample.compose.util.DeepLinkMatcher +import co.nimblehq.sample.compose.util.DeepLinkPattern +import co.nimblehq.sample.compose.util.DeepLinkRequest +import co.nimblehq.sample.compose.util.KeyDecoder +import co.nimblehq.sample.compose.util.LocalResultEventBus +import co.nimblehq.sample.compose.util.ResultEventBus import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +private const val TWEEN_DURATION_IN_MILLIS = 500 @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + + internal val deepLinkPatterns: List> = listOf( + DeepLinkPattern(DetailsScreen.Search.serializer(), URL_SEARCH.toUri()), + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setEdgeToEdgeConfig() + handleNewIntent(intent) setContent { + val eventBus = remember { ResultEventBus() } + ComposeTheme { - AppNavGraph(navController = rememberNavController()) + CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + }, + popTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + }, + predictivePopTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(TWEEN_DURATION_IN_MILLIS) + ) + } + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNewIntent(intent) + } + + private fun handleNewIntent(intent: Intent) { + val uri: Uri? = intent.data + val deepLinkNavKey: Any? = uri?.let { + val request = DeepLinkRequest(uri) + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() } + match?.let { + KeyDecoder(match.args) + .decodeSerializableValue(match.serializer) + } + } + + if (deepLinkNavKey != null) { + navigator.goTo(deepLinkNavKey) + intent.data = null } } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt new file mode 100644 index 000000000..21326a3a1 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsScreen.kt @@ -0,0 +1,158 @@ +package co.nimblehq.sample.compose.ui.screens.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.extensions.collectAsEffect +import co.nimblehq.sample.compose.lib.IsLoading +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.models.UiModel +import co.nimblehq.sample.compose.ui.showToast +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.coroutines.flow.collectLatest +import kotlinx.serialization.Serializable + +@Serializable +sealed class DetailsScreen : NavKey { + @Serializable + data class Details(val id: Int) : DetailsScreen() + + @Serializable + data class Search(val username: String) : DetailsScreen() +} + +@Composable +fun DetailsScreen( + viewModel: DetailsViewModel, + navigateToLoginOrRegister: () -> Unit, + onClickBack: () -> Unit, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + val context = LocalContext.current + viewModel.error.collectAsEffect { e -> e.showToast(context) } + + LaunchedEffect(Unit) { + viewModel.onLoginRequired.collectLatest { + navigateToLoginOrRegister() + } + } + + val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val uiModel: UiModel? by viewModel.uiModel.collectAsStateWithLifecycle() + val isLiked: Boolean by viewModel.isLiked.collectAsStateWithLifecycle() + val isFromDeepLink: Boolean by viewModel.isFromDeepLink.collectAsStateWithLifecycle() + val username: String? by viewModel.username.collectAsStateWithLifecycle() + + DetailsScreenContent( + uiModel = uiModel, + isLoading = isLoading, + isLiked = isLiked, + isFromDeepLink = isFromDeepLink, + username = username, + onClickBack = onClickBack, + onClickLike = viewModel::onClickLike, + ) +} + +@Composable +private fun DetailsScreenContent( + uiModel: UiModel?, + isLoading: IsLoading, + isLiked: Boolean, + isFromDeepLink: Boolean, + username: String?, + onClickBack: () -> Unit, + onClickLike: () -> Unit, +) { + Scaffold( + topBar = { + AppBar( + title = R.string.details_title, + onClickBack = onClickBack, + actions = { + if (!isFromDeepLink) { + IconButton(onClick = onClickLike) { + Icon( + imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + contentDescription = stringResource(R.string.test_tag_favorite_button) + ) + } + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + uiModel?.let { model -> + Column( + modifier = Modifier.padding(dimensions.spacingLarge) + ) { + Text(text = "ID: ${model.id}") + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + Text(text = "Username: ${model.username}") + + username?.let { + Spacer(modifier = Modifier.height(dimensions.spacingLarge)) + Text(text = stringResource(R.string.welcome_back, it)) + } + } + } ?: run { + Text( + text = "No data available", + modifier = Modifier + .align(Alignment.Center) + .padding(dimensions.spacingLarge) + ) + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun DetailsScreenPreview() { + ComposeTheme { + DetailsScreenContent( + uiModel = UiModel("1", "testuser"), + isLoading = false, + isLiked = false, + isFromDeepLink = false, + username = null, + onClickBack = {}, + onClickLike = {}, + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt new file mode 100644 index 000000000..f54e3f9ff --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/details/DetailsViewModel.kt @@ -0,0 +1,98 @@ +package co.nimblehq.sample.compose.ui.screens.details + +import androidx.lifecycle.viewModelScope +import co.nimblehq.sample.compose.domain.usecases.GetDetailsUseCase +import co.nimblehq.sample.compose.domain.usecases.SearchUserUseCase +import co.nimblehq.sample.compose.ui.base.BaseViewModel +import co.nimblehq.sample.compose.ui.models.UiModel +import co.nimblehq.sample.compose.ui.models.toUiModel +import co.nimblehq.sample.compose.util.DispatchersProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class) +class DetailsViewModel @AssistedInject constructor( + @Assisted private val key: DetailsScreen, + private val getDetailsUseCase: GetDetailsUseCase, + private val searchUserUseCase: SearchUserUseCase, + private val dispatchersProvider: DispatchersProvider, +) : BaseViewModel() { + + private val _uiModel = MutableStateFlow(null) + val uiModel = _uiModel.asStateFlow() + + private val _isLiked = MutableStateFlow(false) + val isLiked = _isLiked.asStateFlow() + + private val _isFromDeepLink = MutableStateFlow(false) + val isFromDeepLink = _isFromDeepLink.asStateFlow() + + private val _username = MutableStateFlow(null) + val username = _username.asStateFlow() + + private val _onLoginRequired = MutableSharedFlow() + val onLoginRequired = _onLoginRequired.asSharedFlow() + + init { + when (key) { + is DetailsScreen.Details -> loadDetails(key.id) + is DetailsScreen.Search -> searchUser(key.username) + } + } + + private fun loadDetails(id: Int) { + getDetailsUseCase(id) + .injectLoading() + .onEach { model -> + _uiModel.emit(model.toUiModel()) + _isFromDeepLink.emit(false) + } + .flowOn(dispatchersProvider.io) + .catch { e -> _error.emit(e) } + .launchIn(viewModelScope) + } + + private fun searchUser(username: String) { + searchUserUseCase(username) + .injectLoading() + .onEach { models -> + _uiModel.emit(models.firstOrNull()?.toUiModel()) + _isFromDeepLink.emit(true) + } + .flowOn(dispatchersProvider.io) + .catch { e -> _error.emit(e) } + .launchIn(viewModelScope) + } + + fun onClickLike() { + launch { + if (!_isLiked.value) { + _onLoginRequired.emit(Unit) + } else { + _isLiked.emit(false) + } + } + } + + fun changeUsername(username: String) { + launch { + _username.emit(username) + _isLiked.emit(true) + } + } + + @AssistedFactory + interface Factory { + fun create(key: DetailsScreen): DetailsViewModel + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt similarity index 50% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt index a4d4d6fd1..262d2a5fe 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/Item.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/Item.kt @@ -1,36 +1,28 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.theme.* import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme -@OptIn(ExperimentalFoundationApi::class) @Composable fun Item( uiModel: UiModel, onClick: (UiModel) -> Unit, - onLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { - var expanded by remember { mutableStateOf(false) } - Box( modifier = modifier .fillMaxWidth() - .combinedClickable( - onClick = { onClick(uiModel) }, - onLongClick = { expanded = true } - ) + .clickable { onClick(uiModel) } ) { Row { Text( @@ -46,17 +38,6 @@ fun Item( text = uiModel.username ) } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(id = R.string.third_edit_menu_title)) }, - onClick = { onLongClick(uiModel) } - ) - - } } } @@ -67,7 +48,6 @@ private fun ItemPreview() { Item( uiModel = UiModel("1", "name1"), onClick = {}, - onLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt similarity index 86% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt index f2f6d60d7..7ec7a66dd 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/ItemList.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ItemList.kt @@ -1,4 +1,4 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,7 +15,6 @@ import kotlinx.collections.immutable.persistentListOf fun ItemList( uiModels: ImmutableList, onItemClick: (UiModel) -> Unit, - onItemLongClick: (UiModel) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier) { @@ -23,7 +22,6 @@ fun ItemList( Item( uiModel = uiModel, onClick = onItemClick, - onLongClick = onItemLongClick ) HorizontalDivider() } @@ -37,7 +35,6 @@ private fun ItemListPreview() { ItemList( uiModels = persistentListOf(UiModel("1", "name1"), UiModel("2", "name2"), UiModel("3", "name3")), onItemClick = {}, - onItemLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt similarity index 71% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt index c6e2bccfc..d94e95733 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreen.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListScreen.kt @@ -1,10 +1,15 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import android.Manifest.permission.CAMERA +import android.annotation.SuppressLint 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.Search import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,13 +18,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import co.nimblehq.sample.compose.R import co.nimblehq.sample.compose.extensions.collectAsEffect import co.nimblehq.sample.compose.extensions.showToast import co.nimblehq.sample.compose.lib.IsLoading -import co.nimblehq.sample.compose.ui.base.BaseDestination import co.nimblehq.sample.compose.ui.base.BaseScreen import co.nimblehq.sample.compose.ui.common.AppBar import co.nimblehq.sample.compose.ui.models.UiModel @@ -31,18 +36,23 @@ import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable +@Serializable +data object ListScreen : NavKey + +@SuppressLint("LocalContextGetResourceValueCall") @Composable -fun HomeScreen( - isResultOk: Boolean = false, - navigator: (destination: BaseDestination) -> Unit, - viewModel: HomeViewModel = hiltViewModel(), +fun ListScreen( + onClickSearch: () -> Unit, + onItemClick: (UiModel) -> Unit, + modifier: Modifier = Modifier, + viewModel: ListViewModel = hiltViewModel(), ) = BaseScreen( isDarkStatusBarIcons = true, ) { val context = LocalContext.current viewModel.error.collectAsEffect { e -> e.showToast(context) } - viewModel.navigator.collectAsEffect { destination -> navigator(destination) } val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle() val uiModels: ImmutableList by viewModel.uiModels.collectAsStateWithLifecycle() @@ -55,55 +65,40 @@ fun HomeScreen( } } - LaunchedEffect(Unit) { - if (isResultOk) { - context.showToast(context.getString(R.string.message_updated)) - } - } - CameraPermission() - HomeScreenContent( + ListScreenContent( uiModels = uiModels, isLoading = isLoading, - onItemClick = viewModel::navigateToSecond, - onItemLongClick = viewModel::navigateToThird + onClickSearch = onClickSearch, + onItemClick = onItemClick, + modifier = modifier, ) } -/** - * [CameraPermission] needs to be separate from [HomeScreenContent] to avoid re-composition - */ -@OptIn(ExperimentalPermissionsApi::class) @Composable -private fun CameraPermission() { - val context = LocalContext.current - val cameraPermissionState = rememberPermissionState(CAMERA) - if (cameraPermissionState.status.isGranted) { - context.showToast("${cameraPermissionState.permission} granted") - } else { - if (cameraPermissionState.status.shouldShowRationale) { - context.showToast("${cameraPermissionState.permission} needs rationale") - } else { - context.showToast("Request cancelled, missing permissions in manifest or denied permanently") - } - - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - } -} - -@Composable -private fun HomeScreenContent( +private fun ListScreenContent( uiModels: ImmutableList, isLoading: IsLoading, + onClickSearch: () -> Unit, onItemClick: (UiModel) -> Unit, - onItemLongClick: (UiModel) -> Unit, + modifier: Modifier = Modifier, ) { - Scaffold(topBar = { - AppBar(R.string.home_title_bar) - }) { paddingValues -> + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.list_title, + actions = { + IconButton(onClick = onClickSearch) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search" + ) + } + } + ) + }) { paddingValues -> Box( modifier = Modifier .fillMaxSize() @@ -115,22 +110,44 @@ private fun HomeScreenContent( ItemList( uiModels = uiModels, onItemClick = onItemClick, - onItemLongClick = onItemLongClick ) } } } } +/** + * [CameraPermission] needs to be separate from [ListScreenContent] to avoid re-composition + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun CameraPermission() { + val context = LocalContext.current + val cameraPermissionState = rememberPermissionState(CAMERA) + if (cameraPermissionState.status.isGranted) { + context.showToast("${cameraPermissionState.permission} granted") + } else { + if (cameraPermissionState.status.shouldShowRationale) { + context.showToast("${cameraPermissionState.permission} needs rationale") + } else { + context.showToast("Request cancelled, missing permissions in manifest or denied permanently") + } + + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + } +} + @Preview(showSystemUi = true) @Composable -private fun HomeScreenPreview() { +private fun ListScreenPreview() { ComposeTheme { - HomeScreenContent( + ListScreenContent( uiModels = persistentListOf(UiModel("1", "name1"), UiModel("2", "name2"), UiModel("3", "name3")), isLoading = false, + onClickSearch = {}, onItemClick = {}, - onItemLongClick = {} ) } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt similarity index 84% rename from sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt rename to sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt index 00ec8dca3..a7a2d80a0 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModel.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/list/ListViewModel.kt @@ -1,4 +1,4 @@ -package co.nimblehq.sample.compose.ui.screens.main.home +package co.nimblehq.sample.compose.ui.screens.list import androidx.lifecycle.viewModelScope import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase @@ -7,7 +7,6 @@ import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferenc import co.nimblehq.sample.compose.ui.base.BaseViewModel import co.nimblehq.sample.compose.ui.models.UiModel import co.nimblehq.sample.compose.ui.models.toUiModel -import co.nimblehq.sample.compose.ui.screens.main.MainDestination import co.nimblehq.sample.compose.util.DispatchersProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList @@ -22,7 +21,7 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor( +class ListViewModel @Inject constructor( getModelsUseCase: GetModelsUseCase, isFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase, private val updateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase, @@ -61,12 +60,4 @@ class HomeViewModel @Inject constructor( _isFirstTimeLaunch.emit(false) } } - - fun navigateToSecond(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Second.createRoute(uiModel.id)) } - } - - fun navigateToThird(uiModel: UiModel) { - launch { _navigator.emit(MainDestination.Third.addParcel(uiModel)) } - } } diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt new file mode 100644 index 000000000..538e6e2a5 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginOrRegisterScreen.kt @@ -0,0 +1,112 @@ +package co.nimblehq.sample.compose.ui.screens.login + +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.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import co.nimblehq.sample.compose.util.buildAnnotatedStringWithPart +import kotlinx.serialization.Serializable + +@Serializable +data object LoginOrRegisterScreen : NavKey + +@Composable +fun LoginOrRegisterScreen( + navigateToLogin: () -> Unit, + navigateToRegister: () -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.login, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = stringResource(R.string.login_required), + modifier = Modifier.padding(dimensions.spacingMedium) + ) + + Button( + onClick = navigateToLogin, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.login)) + } + + OutlinedButton( + onClick = navigateToRegister, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.register_here)) + } + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + val fullText = stringResource(R.string.do_you_have_account) + val clickablePart = stringResource(R.string.register_here) + val annotatedString = buildAnnotatedStringWithPart( + fullText = fullText, + clickablePart = clickablePart, + clickableStyle = SpanStyle( + color = Color.Blue, + fontWeight = FontWeight.Bold + ), + onClick = navigateToRegister, + ) + + Text(text = annotatedString) + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun LoginOrRegisterScreenPreview() { + ComposeTheme { + LoginOrRegisterScreen( + navigateToLogin = {}, + navigateToRegister = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt new file mode 100644 index 000000000..812aabb51 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/login/LoginScreen.kt @@ -0,0 +1,103 @@ +package co.nimblehq.sample.compose.ui.screens.login + +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.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen : NavKey + +@Composable +fun LoginScreen( + navigateToDetails: (String) -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + var username by remember { mutableStateOf("") } + + val usernameFieldTag = stringResource(R.string.test_tag_user_name_field) + val loginButtonTag = stringResource(R.string.test_tag_login_button) + + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.login, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.username)) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = usernameFieldTag } + ) + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + Button( + onClick = { navigateToDetails(username) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = loginButtonTag }, + enabled = username.isNotEmpty() + ) { + Text(stringResource(R.string.login)) + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun LoginScreenPreview() { + ComposeTheme { + LoginScreen( + navigateToDetails = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt deleted file mode 100644 index 9383fb055..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainDestination.kt +++ /dev/null @@ -1,31 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main - -import androidx.navigation.NavType -import androidx.navigation.navArgument -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.models.UiModel - -const val KeyId = "id" -const val KeyModel = "model" - -sealed class MainDestination { - - object Home : BaseDestination("home") - - object Second : BaseDestination("second/{$KeyId}") { - - override val arguments = listOf( - navArgument(KeyId) { type = NavType.StringType } - ) - - fun createRoute(id: String) = apply { - destination = "second/$id" - } - } - - object Third : BaseDestination("third") { - fun addParcel(value: UiModel) = apply { - parcelableArgument = KeyModel to value - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt deleted file mode 100644 index 1632ceaad..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/MainNavGraph.kt +++ /dev/null @@ -1,50 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.navigation -import co.nimblehq.sample.compose.extensions.getThenRemove -import co.nimblehq.sample.compose.ui.AppDestination -import co.nimblehq.sample.compose.ui.base.KeyResultOk -import co.nimblehq.sample.compose.ui.composable -import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.navigate -import co.nimblehq.sample.compose.ui.screens.main.home.HomeScreen -import co.nimblehq.sample.compose.ui.screens.main.second.SecondScreen -import co.nimblehq.sample.compose.ui.screens.main.third.ThirdScreen - -fun NavGraphBuilder.mainNavGraph( - navController: NavHostController, -) { - navigation( - route = AppDestination.MainNavGraph.route, - startDestination = MainDestination.Home.destination - ) { - composable(destination = MainDestination.Home) { backStackEntry -> - val isResultOk = backStackEntry.savedStateHandle - .getThenRemove(KeyResultOk) ?: false - HomeScreen( - navigator = { destination -> - navController.navigate(destination, destination.parcelableArgument) - }, - isResultOk = isResultOk, - ) - } - - composable(destination = MainDestination.Second) { backStackEntry -> - SecondScreen( - navigator = { destination -> navController.navigate(destination) }, - id = backStackEntry.arguments?.getString(KeyId).orEmpty() - ) - } - - composable(destination = MainDestination.Third) { - ThirdScreen( - navigator = { destination -> navController.navigate(destination) }, - model = navController.previousBackStackEntry?.savedStateHandle?.get( - KeyModel - ) - ) - } - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt deleted file mode 100644 index 98c692229..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondScreen.kt +++ /dev/null @@ -1,84 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.second - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.base.BaseScreen -import co.nimblehq.sample.compose.ui.base.KeyResultOk -import co.nimblehq.sample.compose.ui.common.AppBar -import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions -import co.nimblehq.sample.compose.ui.theme.ComposeTheme - -@Composable -fun SecondScreen( - id: String, - navigator: (destination: BaseDestination) -> Unit, - viewModel: SecondViewModel = hiltViewModel(), -) = BaseScreen( - isDarkStatusBarIcons = false, -) { - SecondScreenContent( - id = id, - onUpdateClick = { - navigator(BaseDestination.Up().addResult(KeyResultOk, true)) - }, - ) -} - -@Composable -private fun SecondScreenContent( - id: String, - onUpdateClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - AppBar(R.string.second_title_bar) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Column(modifier = Modifier.align(Alignment.Center)) { - Text( - text = stringResource(R.string.second_id_title, id), - ) - - Button( - onClick = { onUpdateClick() }, - modifier = Modifier.padding(dimensions.spacingMedium) - ) { - Text( - text = stringResource(R.string.second_update) - ) - } - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun SecondScreenPreview() { - ComposeTheme { - SecondScreenContent( - id = "1", - onUpdateClick = {}, - ) - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt deleted file mode 100644 index 3abb1ab4e..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/second/SecondViewModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.second - -import co.nimblehq.sample.compose.ui.base.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class SecondViewModel @Inject constructor() : BaseViewModel() diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt deleted file mode 100644 index 98ffc5c74..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdScreen.kt +++ /dev/null @@ -1,64 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.third - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.base.BaseScreen -import co.nimblehq.sample.compose.ui.common.AppBar -import co.nimblehq.sample.compose.ui.models.UiModel -import co.nimblehq.sample.compose.ui.theme.ComposeTheme - -@Composable -fun ThirdScreen( - model: UiModel?, - navigator: (destination: BaseDestination) -> Unit, - viewModel: ThirdViewModel = hiltViewModel(), -) = BaseScreen( - isDarkStatusBarIcons = true, -) { - ThirdScreenContent(data = model) -} - -@Composable -private fun ThirdScreenContent( - data: UiModel?, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - AppBar(title = R.string.third_title_bar) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Text( - text = stringResource(R.string.third_data_title, data.toString()), - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } - } -} - -@Preview -@Composable -private fun ThirdScreenPreview() { - ComposeTheme { - ThirdScreenContent(data = UiModel("1", "name1")) - } -} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt deleted file mode 100644 index 60aea8133..000000000 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/main/third/ThirdViewModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.third - -import co.nimblehq.sample.compose.ui.base.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class ThirdViewModel @Inject constructor() : BaseViewModel() diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt new file mode 100644 index 000000000..cc8af7de5 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/ui/screens/search/SearchScreen.kt @@ -0,0 +1,134 @@ +package co.nimblehq.sample.compose.ui.screens.search + +import android.content.ClipData +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.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import co.nimblehq.sample.compose.R +import co.nimblehq.sample.compose.ui.base.BaseScreen +import co.nimblehq.sample.compose.ui.common.AppBar +import co.nimblehq.sample.compose.ui.common.PATH_BASE +import co.nimblehq.sample.compose.ui.common.PATH_SEARCH +import co.nimblehq.sample.compose.ui.theme.AppTheme.dimensions +import co.nimblehq.sample.compose.ui.theme.ComposeTheme +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +data object SearchScreen : NavKey + +@Composable +fun SearchScreen( + onClickCreateDeeplink: (String) -> Unit, + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) = BaseScreen( + isDarkStatusBarIcons = true, +) { + var username by remember { mutableStateOf("") } + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + topBar = { + AppBar( + title = R.string.search_title, + onClickBack = onClickBack + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(dimensions.spacingLarge), + verticalArrangement = Arrangement.spacedBy(dimensions.spacingMedium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.enter_username)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + Button( + onClick = { onClickCreateDeeplink(username) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.create_deeplink)) + } + + Spacer(modifier = Modifier.height(dimensions.spacingMedium)) + + // Show deeplink preview + if (username.isNotEmpty()) { + val deeplink = "$PATH_BASE/$PATH_SEARCH?username=$username" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Deeplink preview:\n$deeplink", + modifier = Modifier + .padding(dimensions.spacingMedium) + .weight(1f) + ) + IconButton(onClick = { + scope.launch { + clipboardManager.setClipEntry( + ClipData.newPlainText("deeplink", deeplink).toClipEntry(), + ) + } + }) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Copy deeplink", + ) + } + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun SearchScreenPreview() { + ComposeTheme { + SearchScreen( + onClickCreateDeeplink = {}, + onClickBack = {} + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt new file mode 100644 index 000000000..f3dc0c342 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/AnnotatedStringUtil.kt @@ -0,0 +1,35 @@ +package co.nimblehq.sample.compose.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +fun buildAnnotatedStringWithPart( + fullText: String, + clickablePart: String, + clickableStyle: SpanStyle, + onClick: () -> Unit, +): AnnotatedString { + return buildAnnotatedString { + val startIndex = fullText.indexOf(clickablePart) + if (startIndex != -1) { + append(fullText.take(startIndex)) + withLink( + LinkAnnotation.Clickable( + tag = "CLICKABLE", + linkInteractionListener = { onClick() } + ) + ) { + withStyle(style = clickableStyle) { + append(clickablePart) + } + } + append(fullText.substring(startIndex + clickablePart.length)) + } else { + append(fullText) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt index f4830423f..290a6030e 100644 --- a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ComposableUtil.kt @@ -1,14 +1,12 @@ package co.nimblehq.sample.compose.util -import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import com.google.accompanist.systemuicontroller.rememberSystemUiController -@SuppressLint("ComposableNaming") @Composable -fun setStatusBarColor( +fun StatusBarColor( color: Color, darkIcons: Boolean, ) { diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt new file mode 100644 index 000000000..4f339784d --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkMatcher.kt @@ -0,0 +1,81 @@ +package co.nimblehq.sample.compose.util + +import android.util.Log +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern, +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if (requestedSegment != candidateSegment.stringValue) { + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + val queryStringParser = deepLinkPattern.queryValueParsers[name] + queryStringParser?.let { + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map, +) + +const val TAG_LOG_ERROR = "SampleComposeDeepLink" diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt new file mode 100644 index 000000000..8c479b617 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkPattern.kt @@ -0,0 +1,120 @@ +package co.nimblehq.sample.compose.util + +import android.net.Uri +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +@OptIn(ExperimentalSerializationApi::class) +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri, +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser, + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +@Suppress(" ComplexMethod ") +@OptIn(ExperimentalSerializationApi::class) +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> { it -> it.first() } + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt new file mode 100644 index 000000000..45216527c --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/DeepLinkRequest.kt @@ -0,0 +1,28 @@ +package co.nimblehq.sample.compose.util + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName) ?: "" + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt new file mode 100644 index 000000000..436f11b59 --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/KeyDecoder.kt @@ -0,0 +1,70 @@ +package co.nimblehq.sample.compose.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt new file mode 100644 index 000000000..3c47a380d --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEffect.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package co.nimblehq.sample.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +// Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEffect.kt + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + crossinline onResult: suspend (T) -> Unit +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + } + } +} diff --git a/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt new file mode 100644 index 000000000..97c891f2f --- /dev/null +++ b/sample-compose/app/src/main/java/co/nimblehq/sample/compose/util/ResultEventBus.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package co.nimblehq.sample.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +// Reference: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/ResultEventBus.kt + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +class ResultEventBus { + /** + * Map from the result key to a channel of results. + */ + val channelMap: MutableMap> = mutableMapOf() + + /** + * Provides a flow for the given resultKey. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + /** + * Sends a result into the channel associated with the given resultKey. + */ + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + channelMap[resultKey]?.trySend(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap[resultKey]?.close() + channelMap.remove(resultKey) + } +} diff --git a/sample-compose/app/src/main/res/values/strings.xml b/sample-compose/app/src/main/res/values/strings.xml index d21fee171..25e784bb5 100644 --- a/sample-compose/app/src/main/res/values/strings.xml +++ b/sample-compose/app/src/main/res/values/strings.xml @@ -2,17 +2,30 @@ Sample Compose Unexpected error + This is the first time launch - Home + + List + Details + Search - Second - ID: %1$s - Update + + Enter username + Create Deeplink - Third - Data: %s - Edit + + Login required to like + Welcome back, %s! - This is the first time launch - Updated! + + Do you have an account? + Login + Register here + Username + + + Back + Favorite + Username Field + Login Button diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt new file mode 100644 index 000000000..01a7cd3d1 --- /dev/null +++ b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/FakeNavigator.kt @@ -0,0 +1,36 @@ +package co.nimblehq.sample.compose.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import co.nimblehq.sample.compose.navigation.Navigator +import kotlin.reflect.KClass + +class FakeNavigator : Navigator { + override val backStack: SnapshotStateList = mutableStateListOf() + + override fun goTo(destination: Any) { + backStack.add(destination) + } + + override fun goBack() { + backStack.removeLastOrNull() + } + + override fun goBackToLast(destinationClass: KClass<*>) { + val index = backStack.indexOfLast { + destinationClass.isInstance(it) + } + + if (index in backStack.indices) { + backStack.removeRange(index + 1, backStack.size) + } + } + + fun addToBackStack(listOfPastDestinations: List) { + backStack.addAll(listOfPastDestinations) + } + + fun currentScreen(): Any? = backStack.lastOrNull() + + fun currentScreenClass(): KClass<*>? = backStack.lastOrNull()?.let { it::class } +} diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt deleted file mode 100644 index c8126dad7..000000000 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeScreenTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import androidx.activity.compose.setContent -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.rule.GrantPermissionRule -import co.nimblehq.sample.compose.R -import co.nimblehq.sample.compose.domain.usecases.* -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.ui.base.BaseDestination -import co.nimblehq.sample.compose.ui.screens.BaseScreenTest -import co.nimblehq.sample.compose.ui.screens.MainActivity -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.theme.ComposeTheme -import io.kotest.matchers.shouldBe -import io.mockk.* -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import org.junit.* -import org.junit.Assert.assertEquals -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.shadows.ShadowToast - -@RunWith(RobolectricTestRunner::class) -class HomeScreenTest : BaseScreenTest() { - - @get:Rule - val composeRule = createAndroidComposeRule() - - /** - * More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/ - */ - @get:Rule - val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.CAMERA - ) - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = - mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = - mockk() - - private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null - - @Before - fun setUp() { - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false) - coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs - } - - @Test - fun `When entering the Home screen for the first time, it shows a toast confirming that`() { - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(true) - - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.message_first_time_launch)) shouldBe true - } - } - - @Test - fun `When entering the Home screen NOT for the first time, it doesn't show the toast confirming that`() { - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.message_first_time_launch)) shouldBe false - } - } - - @Test - fun `When entering the Home screen and loading the list item successfully, it shows the list item correctly`() = - initComposable { - onNodeWithText("Home").assertIsDisplayed() - - onNodeWithText("1").assertIsDisplayed() - onNodeWithText("2").assertIsDisplayed() - onNodeWithText("3").assertIsDisplayed() - } - - @Test - fun `When entering the Home screen and loading the list item failure, it shows the corresponding error`() { - setStandardTestDispatcher() - - val error = Exception() - every { mockGetModelsUseCase() } returns flow { throw error } - - initComposable { - composeRule.waitForIdle() - advanceUntilIdle() - - ShadowToast.showedToast(activity.getString(R.string.error_generic)) shouldBe true - } - } - - @Test - fun `When clicking on a list item, it navigates to Second screen`() = initComposable { - onNodeWithText("1").performClick() - - assertEquals(expectedDestination, MainDestination.Second) - } - - private fun initComposable( - testBody: AndroidComposeTestRule, MainActivity>.() -> Unit, - ) { - initViewModel() - - composeRule.activity.setContent { - ComposeTheme { - HomeScreen( - isResultOk = false, - viewModel = viewModel, - navigator = { destination -> expectedDestination = destination }, - ) - } - } - testBody(composeRule) - } - - private fun initViewModel() { - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - coroutinesRule.testDispatcherProvider - ) - } -} diff --git a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt b/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt deleted file mode 100644 index 1d8de6c1d..000000000 --- a/sample-compose/app/src/test/java/co/nimblehq/sample/compose/ui/screens/main/home/HomeViewModelTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package co.nimblehq.sample.compose.ui.screens.main.home - -import app.cash.turbine.test -import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase -import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase -import co.nimblehq.sample.compose.test.CoroutineTestRule -import co.nimblehq.sample.compose.test.MockUtil -import co.nimblehq.sample.compose.ui.screens.main.MainDestination -import co.nimblehq.sample.compose.ui.models.toUiModel -import co.nimblehq.sample.compose.util.DispatchersProvider -import io.kotest.matchers.shouldBe -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class HomeViewModelTest { - - @get:Rule - val coroutinesRule = CoroutineTestRule() - - private val mockGetModelsUseCase: GetModelsUseCase = mockk() - private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk() - private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk() - - private lateinit var viewModel: HomeViewModel - - private val isFirstTimeLaunch = false - - @Before - fun setUp() { - every { mockGetModelsUseCase() } returns flowOf(MockUtil.models) - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(isFirstTimeLaunch) - coEvery { mockUpdateFirstTimeLaunchPreferencesUseCase(any()) } just Runs - - initViewModel() - } - - @Test - fun `When loading models successfully, it shows the model list`() = runTest { - viewModel.uiModels.test { - expectMostRecentItem() shouldBe MockUtil.models.map { it.toUiModel() } - } - } - - @Test - fun `When loading models failed, it shows the corresponding error`() = runTest { - val error = Exception() - every { mockGetModelsUseCase() } returns flow { throw error } - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.error.test { - advanceUntilIdle() - - expectMostRecentItem() shouldBe error - } - } - - @Test - fun `When loading models, it shows and hides loading correctly`() = runTest { - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.isLoading.test { - awaitItem() shouldBe false - awaitItem() shouldBe true - awaitItem() shouldBe false - } - } - - @Test - fun `When calling navigate to Second, it navigates to Second screen`() = runTest { - viewModel.navigator.test { - viewModel.navigateToSecond(MockUtil.models[0].toUiModel()) - - expectMostRecentItem() shouldBe MainDestination.Second - } - } - - @Test - fun `When initializing the ViewModel, it emits whether the app is launched for the first time accordingly`() = - runTest { - viewModel.isFirstTimeLaunch.first() shouldBe isFirstTimeLaunch - } - - @Test - fun `When initializing the ViewModel and isFirstTimeLaunchPreferencesUseCase returns error, it shows the corresponding error`() = - runTest { - val error = Exception() - every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flow { throw error } - - initViewModel(dispatchers = CoroutineTestRule(StandardTestDispatcher()).testDispatcherProvider) - - viewModel.error.test { - advanceUntilIdle() - - expectMostRecentItem() shouldBe error - } - } - - @Test - fun `When launching the app for the first time, it executes the use case and emits value accordingly`() = - runTest { - viewModel.onFirstTimeLaunch() - - coVerify(exactly = 1) { - mockUpdateFirstTimeLaunchPreferencesUseCase(false) - } - viewModel.isFirstTimeLaunch.first() shouldBe false - } - - private fun initViewModel(dispatchers: DispatchersProvider = coroutinesRule.testDispatcherProvider) { - viewModel = HomeViewModel( - mockGetModelsUseCase, - mockIsFirstTimeLaunchPreferencesUseCase, - mockUpdateFirstTimeLaunchPreferencesUseCase, - dispatchers - ) - } -} diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt index d0a0ee510..456b5d339 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/models/responses/Response.kt @@ -11,7 +11,7 @@ data class Response( val userName: String?, ) -private fun Response.toModel() = Model( +fun Response.toModel() = Model( id = this.id, username = this.userName, ) diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt index 2eb702d9e..434f4bf22 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/remote/services/ApiService.kt @@ -2,9 +2,17 @@ package co.nimblehq.sample.compose.data.remote.services import co.nimblehq.sample.compose.data.remote.models.responses.Response import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query interface ApiService { @GET("users") suspend fun getResponses(): List + + @GET("users/{id}") + suspend fun getDetails(@Path("id") id: Int): Response + + @GET("users") + suspend fun searchUser(@Query("username") username: String): List } diff --git a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt index eb939659a..209744d85 100644 --- a/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt +++ b/sample-compose/data/src/main/java/co/nimblehq/sample/compose/data/repositories/RepositoryImpl.kt @@ -1,6 +1,7 @@ package co.nimblehq.sample.compose.data.repositories import co.nimblehq.sample.compose.data.extensions.flowTransform +import co.nimblehq.sample.compose.data.remote.models.responses.toModel import co.nimblehq.sample.compose.data.remote.models.responses.toModels import co.nimblehq.sample.compose.data.remote.services.ApiService import co.nimblehq.sample.compose.domain.models.Model @@ -14,4 +15,12 @@ class RepositoryImpl constructor( override fun getModels(): Flow> = flowTransform { apiService.getResponses().toModels() } + + override fun getDetails(id: Int): Flow = flowTransform { + apiService.getDetails(id).toModel() + } + + override fun searchUser(username: String): Flow> = flowTransform { + apiService.searchUser(username).toModels() + } } diff --git a/sample-compose/domain/build.gradle.kts b/sample-compose/domain/build.gradle.kts index 73377e18a..f75166acb 100644 --- a/sample-compose/domain/build.gradle.kts +++ b/sample-compose/domain/build.gradle.kts @@ -9,6 +9,10 @@ java { targetCompatibility = JavaVersion.VERSION_17 } +kotlin { + jvmToolchain(17) +} + dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.javax.inject) diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt index 47c77b39d..fa1418ea8 100644 --- a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/repositories/Repository.kt @@ -6,4 +6,8 @@ import kotlinx.coroutines.flow.Flow interface Repository { fun getModels(): Flow> + + fun getDetails(id: Int): Flow + + fun searchUser(username: String): Flow> } diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt new file mode 100644 index 000000000..cb1928956 --- /dev/null +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCase.kt @@ -0,0 +1,13 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.models.Model +import co.nimblehq.sample.compose.domain.repositories.Repository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetDetailsUseCase @Inject constructor(private val repository: Repository) { + + operator fun invoke(id: Int): Flow { + return repository.getDetails(id) + } +} diff --git a/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt new file mode 100644 index 000000000..83a2a5454 --- /dev/null +++ b/sample-compose/domain/src/main/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCase.kt @@ -0,0 +1,13 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.models.Model +import co.nimblehq.sample.compose.domain.repositories.Repository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SearchUserUseCase @Inject constructor(private val repository: Repository) { + + operator fun invoke(username: String): Flow> { + return repository.searchUser(username) + } +} diff --git a/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt new file mode 100644 index 000000000..8ad6fb961 --- /dev/null +++ b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/GetDetailsUseCaseTest.kt @@ -0,0 +1,50 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.repositories.Repository +import co.nimblehq.sample.compose.domain.test.MockUtil +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class GetDetailsUseCaseTest { + + private lateinit var mockRepository: Repository + private lateinit var getDetailsUseCase: GetDetailsUseCase + + @Before + fun setUp() { + mockRepository = mockk() + getDetailsUseCase = GetDetailsUseCase(mockRepository) + } + + @Test + fun `When request successful, it returns success`() = runTest { + val expected = MockUtil.models.first() + val id = 1 + every { mockRepository.getDetails(id) } returns flowOf(expected) + + getDetailsUseCase(id).collect { + it shouldBe expected + } + } + + @Test + fun `When request failed, it returns error`() = runTest { + val expected = Exception() + val id = 1 + every { mockRepository.getDetails(id) } returns flow { throw expected } + + getDetailsUseCase(id).catch { + it shouldBe expected + }.collect() + } +} diff --git a/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt new file mode 100644 index 000000000..9158534b5 --- /dev/null +++ b/sample-compose/domain/src/test/java/co/nimblehq/sample/compose/domain/usecases/SearchUserUseCaseTest.kt @@ -0,0 +1,50 @@ +package co.nimblehq.sample.compose.domain.usecases + +import co.nimblehq.sample.compose.domain.repositories.Repository +import co.nimblehq.sample.compose.domain.test.MockUtil +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class SearchUserUseCaseTest { + + private lateinit var mockRepository: Repository + private lateinit var searchUserUseCase: SearchUserUseCase + + @Before + fun setUp() { + mockRepository = mockk() + searchUserUseCase = SearchUserUseCase(mockRepository) + } + + @Test + fun `When request successful, it returns success`() = runTest { + val expected = MockUtil.models + val username = "name1" + every { mockRepository.searchUser(username) } returns flowOf(expected) + + searchUserUseCase(username).collect { + it shouldBe expected + } + } + + @Test + fun `When request failed, it returns error`() = runTest { + val expected = Exception() + val username = "name1" + every { mockRepository.searchUser(username) } returns flow { throw expected } + + searchUserUseCase(username).catch { + it shouldBe expected + }.collect() + } +} diff --git a/sample-compose/gradle/libs.versions.toml b/sample-compose/gradle/libs.versions.toml index f5c7f158b..96f6397fd 100644 --- a/sample-compose/gradle/libs.versions.toml +++ b/sample-compose/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -androidCompileSdk = "35" +androidCompileSdk = "36" androidMinSdk = "24" androidTargetSdk = "34" androidVersionCode = "1" @@ -7,25 +7,25 @@ androidVersionName = "3.32.0" accompanist = "0.30.1" chucker = "4.2.0" -composeBom = "2025.02.00" -# @kaungkhantsoe Will update in a separate PR -composeNavigation = "2.5.3" +composeBom = "2025.12.01" core = "1.15.0" datastore = "1.1.3" detekt = "1.21.0" detektRules = "0.3.3" -gradle = "8.8.2" -hilt = "2.53" -hiltNavigation = "1.2.0" +gradle = "8.9.1" +hilt = "2.54" +hiltLifecycleViewmodel = "1.3.0" javaxInject = "1" junit = "4.13.2" kotest = "5.6.2" kotlin = "2.1.10" kotlinxCollectionsImmutable = "0.3.6" kotlinxCoroutines = "1.7.3" +kotlinxSerialization = "1.7.3" kover = "0.9.1" -ksp = "2.1.0-1.0.29" -lifecycle = "2.8.7" +ksp = "2.1.10-1.0.29" +lifecycle = "2.10.0" +navigation3 = "1.0.0" mockk = "1.13.8" moshi = "1.15.1" nimbleCommon = "0.1.2" @@ -43,8 +43,11 @@ turbine = "0.13.0" androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "core" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -53,13 +56,13 @@ compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } -compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" } +compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } accompanist-systemUiController = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } +hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltLifecycleViewmodel" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } @@ -67,6 +70,7 @@ javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "j kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } # Log timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } @@ -110,17 +114,20 @@ androidx = [ "androidx-core", "androidx-lifecycle-runtime", "androidx-lifecycle-compose", + "androidx-lifecycle-viewmodel-navigation3", + "androidx-navigation3-runtime", + "androidx-navigation3-ui", ] compose = [ "compose-ui", "compose-ui-tooling-preview", "compose-foundation", "compose-material3", - "compose-navigation", + "compose-material-icons-core", ] hilt = [ "hilt-android", - "hilt-navigation", + "hilt-lifecycle-viewmodel-compose", ] retrofit = [ "retrofit", @@ -156,4 +163,5 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/sample-compose/gradle/wrapper/gradle-wrapper.properties b/sample-compose/gradle/wrapper/gradle-wrapper.properties index 7e82773a2..e1ee78772 100644 --- a/sample-compose/gradle/wrapper/gradle-wrapper.properties +++ b/sample-compose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jun 28 08:46:12 ICT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists