diff --git a/README.md b/README.md index 5146b39..63fcc2e 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,10 @@ sense. |[How can I use Jetpack Compose components inside existing screens?](https://github.com/vinaygaba/Learn-Jetpack-Compose-By-Example/blob/master/app/src/main/java/com/example/jetpackcompose/interop/ComposeInClassicAndroidActivity.kt) | | ### Navigation -|Example|Preview| -|-------|-------| -|[How can I navigate to different screen in Jetpack Compose?](https://github.com/vinaygaba/Learn-Jetpack-Compose-By-Example/blob/master/app/src/main/java/com/example/jetpackcompose/navigation/ComposeNavigationActivity.kt) | | +| Example |Preview| +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------| +| [How can I navigate to different screen and send argument in Jetpack Compose?](https://github.com/vinaygaba/Learn-Jetpack-Compose-By-Example/blob/master/app/src/main/java/com/example/jetpackcompose/navigation/compose/ComposeNavigationWithArgActivity.kt) | | +| [How can I navigate to different screen in Jetpack Compose?](https://github.com/vinaygaba/Learn-Jetpack-Compose-By-Example/blob/master/app/src/main/java/com/example/jetpackcompose/navigation/ComposeNavigationActivity.kt) | | ### Testing |Example|Preview| diff --git a/app/build.gradle b/app/build.gradle index ae84e66..d99a156 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,6 +26,11 @@ android { } kotlinOptions { jvmTarget = '11' + + freeCompilerArgs += [ + "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-Xopt-in=androidx.compose.material.ExperimentalMaterialApi", + ] } buildFeatures { compose true diff --git a/app/src/androidTest/java/com/example/jetpackcompose/navigation/compose/NavigationComposeWithArgumentNavigationTest.kt b/app/src/androidTest/java/com/example/jetpackcompose/navigation/compose/NavigationComposeWithArgumentNavigationTest.kt new file mode 100644 index 0000000..f79bf36 --- /dev/null +++ b/app/src/androidTest/java/com/example/jetpackcompose/navigation/compose/NavigationComposeWithArgumentNavigationTest.kt @@ -0,0 +1,82 @@ +package com.example.jetpackcompose.navigation.compose + +import androidx.activity.ComponentActivity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.testing.TestNavHostController +import com.example.jetpackcompose.navigation.assertCurrentRouteName +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class NavigationComposeWithArgumentNavigationTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private lateinit var navController: TestNavHostController + + @Before + fun setupAppNavHost() { + composeTestRule.setContent { + navController = TestNavHostController(LocalContext.current) + navController.navigatorProvider.addNavigator(ComposeNavigator()) + + ComposeNavigationWithArgApp(navController = navController) + } + } + + @Test + fun verifyStartDestination() { + navController + .assertCurrentRouteName("tasks") + } + + @Test + fun verifyBackNavigationNotShownOnStartDestination() { + val backText = "Back" + + composeTestRule + .onNodeWithContentDescription(backText) + .assertDoesNotExist() + } + + @Test + fun clickTask_navigatesToTaskDetailsScreen() { + navController.assertCurrentRouteName("tasks") + + composeTestRule + .onNodeWithText("Contribute to Learn-Jetpack-Compose-By-Example") + .performClick() + + navController.assertCurrentRouteName("tasks/{taskId}") + } + + @Test + fun clickNavigateUpButton_navigatesToTaskListScreen() { + navController.assertCurrentRouteName("tasks") + + navigateToTaskDetails() + + navController.assertCurrentRouteName("tasks/{taskId}") + + val backText = "Back" + + composeTestRule + .onNodeWithContentDescription(backText) + .performClick() + + navController.assertCurrentRouteName("tasks") + } + + private fun navigateToTaskDetails() { + composeTestRule + .onNodeWithText("Contribute to Learn-Jetpack-Compose-By-Example") + .performClick() + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6dc0e12..2c62a84 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -193,6 +193,11 @@ android:exported="true" android:label="@string/title_compose_navigation_example" android:theme="@style/AppTheme.NoActionBar" /> + diff --git a/app/src/main/java/com/example/jetpackcompose/core/MainActivity.kt b/app/src/main/java/com/example/jetpackcompose/core/MainActivity.kt index 1c7ff84..805dd5f 100644 --- a/app/src/main/java/com/example/jetpackcompose/core/MainActivity.kt +++ b/app/src/main/java/com/example/jetpackcompose/core/MainActivity.kt @@ -29,6 +29,7 @@ import com.example.jetpackcompose.material.FlowRowActivity import com.example.jetpackcompose.material.MaterialActivity import com.example.jetpackcompose.material.ShadowActivity import com.example.jetpackcompose.navigation.ComposeNavigationActivity +import com.example.jetpackcompose.navigation.compose.ComposeNavigationWithArgActivity import com.example.jetpackcompose.scrollers.HorizontalScrollableActivity import com.example.jetpackcompose.scrollers.VerticalScrollableActivity import com.example.jetpackcompose.stack.StackActivity @@ -190,4 +191,8 @@ class MainActivity : AppCompatActivity() { fun startComposeNavigationExample(view: View) { startActivity(Intent(this, ComposeNavigationActivity::class.java)) } + + fun startDetailViewComposeNavigationExample(view: View) { + startActivity(Intent(this, ComposeNavigationWithArgActivity::class.java)) + } } diff --git a/app/src/main/java/com/example/jetpackcompose/navigation/compose/ComposeNavigationWithArgActivity.kt b/app/src/main/java/com/example/jetpackcompose/navigation/compose/ComposeNavigationWithArgActivity.kt new file mode 100644 index 0000000..e3b022b --- /dev/null +++ b/app/src/main/java/com/example/jetpackcompose/navigation/compose/ComposeNavigationWithArgActivity.kt @@ -0,0 +1,323 @@ +package com.example.jetpackcompose.navigation.compose + +import android.content.res.Configuration +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ComposeNavigationWithArgActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ComposeNavigationWithArgApp() + } + } +} + +@Composable +fun ComposeNavigationWithArgApp( + navController: NavHostController = rememberNavController() +) { + + val backStackEntry by navController.currentBackStackEntryAsState() + val canNavigateBack by remember(backStackEntry) { + mutableStateOf(navController.previousBackStackEntry != null ) + } + + Scaffold( + topBar = { + MyTopAppBar( + title = "Tasks", + canNavigateBack = canNavigateBack, + navigateUp = { navController.navigateUp() } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) // #1 + ) { + // or you can directly pass the modifier(#1) to AppNavHost(..) + AppNavHost(navController) + } + } +} + +@Composable +private fun MyTopAppBar( + title: String, + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + title = { + Text(text = title) + }, + modifier = modifier, + navigationIcon = if (canNavigateBack) { + { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + } else null, + ) +} + +@Composable +private fun AppNavHost( + navController: NavHostController, +) { + NavHost( + navController = navController, + startDestination = "tasks", + ) { + composable(route = "tasks") { + TaskListScreen( + tasks = DataSource.getAllTasks(), + onTaskClick = { task -> navController.navigate("tasks/${task.id}") } + ) + } + + composable( + route = "tasks/{taskId}", + arguments = listOf(navArgument("taskId") { type = NavType.StringType }), + ) { backStackEntry -> + TaskDetails( + taskId = backStackEntry.arguments?.getString("taskId"), + ) + } + } +} + +@Composable +private fun TaskListScreen( + tasks: List, + onTaskClick: (Task) -> Unit, +) { + + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + stickyHeader { + Text( + text = "Tasks", + style = MaterialTheme.typography.h6 + ) + } + + items(tasks) { task -> + TaskItem(task = task, onItemClick = onTaskClick) + } + } +} + +@Composable +private fun TaskItem( + task: Task, + onItemClick: (Task) -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth(), + onClick = { + onItemClick(task) + } + ) { + Row( + modifier = Modifier + .height(IntrinsicSize.Max) + ) { + Spacer( + modifier = Modifier + .fillMaxHeight() + .width(4.dp) + .background(Color(0xFFFFAA00)) + ) + + Row( + modifier = Modifier.padding(16.dp) + ) { + Text(text = task.title, modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun TaskDetails( + taskId: String?, +) { + + val task by remember { mutableStateOf(DataSource.findTaskById(taskId)) } + + if (task == null) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "Task with id $taskId not found!", + style = MaterialTheme.typography.h6 + ) + } + } else { + // you should avoid using `!!`, + // the data should be wrapped in the ViewState class for type safety. + TaskDetails(task!!) + } +} + +@Composable +private fun TaskDetails(task: Task) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Card( + backgroundColor = Color(0xFFF3F2F2) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "#${task.id}", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + TitleAndLabel(title = "Title", label = task.title) + TitleAndLabel(title = "Description", label = task.description) + TitleAndLabel(title = "Created On", label = task.timestamp) + } + } + } +} + +@Composable +private fun TitleAndLabel( + title: String, + label: String?, +) { + Column { + Text( + text = title, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Text( + text = label ?: "-", + style = MaterialTheme.typography.body1 + ) + } +} + +@Composable +private fun TitleAndLabel( + title: String, + label: Date?, +) { + val simpleDateTimeFormatter = SimpleDateFormat("EEE, dd MMMM yyyy HH:mm", Locale.getDefault()) + val formattedTimestamp = + if (label != null) simpleDateTimeFormatter.format(label) + else "-" + + TitleAndLabel( + title = title, + label = formattedTimestamp + ) +} + +@Preview( + name = "Night Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview( + name = "Day Mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Composable +@Suppress("UnusedPrivateMember", "MagicNumber") +private fun DetailViewComposeNavigationActivityPreview() { + MaterialTheme { + Surface { + ComposeNavigationWithArgApp() + } + } +} + +@Preview( + name = "Night Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview( + name = "Day Mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Composable +@Suppress("UnusedPrivateMember", "MagicNumber") +private fun TaskDetailsPreview() { + MaterialTheme { + Surface { + TaskDetails( + taskId = "1" + ) + } + } +} diff --git a/app/src/main/java/com/example/jetpackcompose/navigation/compose/DataSource.kt b/app/src/main/java/com/example/jetpackcompose/navigation/compose/DataSource.kt new file mode 100644 index 0000000..1276008 --- /dev/null +++ b/app/src/main/java/com/example/jetpackcompose/navigation/compose/DataSource.kt @@ -0,0 +1,45 @@ +package com.example.jetpackcompose.navigation.compose + +import java.util.Calendar +import java.util.Date + +data class Task( + val id: Int, + val title: String, + val description: String? = null, + // I haven't used LocalDateTime as de-sugaring is another concept altogether + val timestamp: Date? = Calendar.getInstance().time, +) + +// You shouldn't use static data source as it makes testing difficult. +object DataSource { + + private val tasks = listOf( + Task( + id = 1, + title = "Contribute to Learn-Jetpack-Compose-By-Example", + description = "Pick a simple issue and try to solve it.", + ), + Task( + id = 2, + title = "Binge Batman Trilogy", + ), + Task( + id = 3, + title = "Buy groceries.", + description = "Don't forget to buy bread and butter!", + timestamp = Calendar.getInstance().time + ), + Task( + id = 4, + title = "Clear out weeds from the backyard", + ), + ) + + fun getAllTasks() = tasks + + fun findTaskById(id: String?): Task? { + val taskId = id?.toIntOrNull() ?: return null + return tasks.firstOrNull { it.id == taskId } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7ff7c7..2995cf8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -677,5 +677,24 @@ android:layout_gravity="center" android:fontFamily="monospace" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8f3e3e..d2f1ad5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ Back Press Example Zoomable Example Compose Navigation Example + Compose Navigation With Arg Example Display Text Display Styled Text @@ -70,4 +71,5 @@ Back Press Component Zoomable Component Compose Navigation + Compose Navigation With Arg diff --git a/app/src/test/java/com.example.jetpackcompose/navigation_compose/DataSourceTest.kt b/app/src/test/java/com.example.jetpackcompose/navigation_compose/DataSourceTest.kt new file mode 100644 index 0000000..d5dfe7b --- /dev/null +++ b/app/src/test/java/com.example.jetpackcompose/navigation_compose/DataSourceTest.kt @@ -0,0 +1,32 @@ +package com.example.jetpackcompose.navigation_compose + +import com.example.jetpackcompose.navigation.compose.DataSource +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataSourceTest { + + @Test + fun verifyTaskListSize() { + val tasks = DataSource.getAllTasks() + val tasksSize = tasks.size + + assertEquals(4, tasksSize) + } + + @Test + fun givenValidTaskIdThenFindTaskByIdShouldReturnTask() { + val taskId = "2" + + val task = DataSource.findTaskById(taskId) + assertEquals(taskId, task?.id.toString()) + } + + @Test + fun givenInvalidTaskIdThenFindTaskByIdShouldReturnNull() { + val taskId = "10" + + val task = DataSource.findTaskById(taskId) + assertEquals(null, task?.id?.toString()) + } +} diff --git a/screenshots/compose_navigation_w_arg_example.gif b/screenshots/compose_navigation_w_arg_example.gif new file mode 100644 index 0000000..e59d8bc Binary files /dev/null and b/screenshots/compose_navigation_w_arg_example.gif differ