diff --git a/README.md b/README.md
index 0a4c1de3..34dc1924 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,8 @@
-
-
+
+
@@ -100,9 +100,12 @@ Please get **Android Studio Bumblebee latest Canary** [from here](https://develo
- __Others:__ After the above steps feel free to deep dive into Tablayouts, carousel, Dialogs and BottomSheets
+### Adaptive layouts (large screens)
+- `NavigationSuiteScaffold` switches between bottom bar, rail and drawer based on window size
+- `ListDetailPaneScaffold` two-pane layout on tablets/foldables — find it under "Adaptive UI" on the home screen
+
## Coming Soon
- Some of the features that will be available in coming weeks
- - Advance lists: Pull Refresh, Swipe lists etc
- Clean Architecture Sample with coroutines.
- Advance canvas drawing.
Much more in pipeline stay tuned!!
diff --git a/app/src/androidTest/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivityTest.kt b/app/src/androidTest/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivityTest.kt
new file mode 100644
index 00000000..8dc083ff
--- /dev/null
+++ b/app/src/androidTest/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivityTest.kt
@@ -0,0 +1,64 @@
+package com.guru.composecookbook.ui.home.adaptive
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import org.junit.Rule
+import org.junit.Test
+
+class AdaptiveUIActivityTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun everyNavigationDestinationIsLabelled() {
+ composeTestRule.setContent { AdaptiveDemoScreen() }
+
+ composeTestRule.onNodeWithText("Albums").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Favorites").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Profile").assertIsDisplayed()
+ }
+
+ @Test
+ fun albumsDestinationIsShownOnLaunch() {
+ composeTestRule.setContent { AdaptiveDemoScreen() }
+
+ composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
+ // Two albums in the sample data are by Ed Sheeran, so the artist alone is not unique.
+ composeTestRule.onAllNodesWithText("Ed Sheeran").onFirst().assertIsDisplayed()
+ }
+
+ @Test
+ fun selectingFavoritesReportsTheCurrentWindowSizeClasses() {
+ composeTestRule.setContent { AdaptiveDemoScreen() }
+
+ composeTestRule.onNodeWithText("Favorites").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Width size class:", substring = true).assertIsDisplayed()
+ composeTestRule.onNodeWithText("Height size class:", substring = true).assertIsDisplayed()
+ }
+
+ @Test
+ fun albumListShowsSongArtistAndGenre() {
+ composeTestRule.setContent { AlbumListDetail() }
+
+ composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
+ // Artist and genre both repeat across the sample data; the song title is the unique field.
+ composeTestRule.onAllNodesWithText("Ed Sheeran").onFirst().assertIsDisplayed()
+ composeTestRule.onAllNodesWithText("Pop").onFirst().assertIsDisplayed()
+ }
+
+ @Test
+ fun tappingAnAlbumOpensItsDetailPane() {
+ composeTestRule.setContent { AlbumListDetail() }
+
+ composeTestRule.onNodeWithText("Perfect").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Album by Ed Sheeran-2016").assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/java/com/guru/composecookbook/ui/home/advancelists/SwipeableListsTest.kt b/app/src/androidTest/java/com/guru/composecookbook/ui/home/advancelists/SwipeableListsTest.kt
new file mode 100644
index 00000000..273e70d5
--- /dev/null
+++ b/app/src/androidTest/java/com/guru/composecookbook/ui/home/advancelists/SwipeableListsTest.kt
@@ -0,0 +1,74 @@
+package com.guru.composecookbook.ui.home.advancelists
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithContentDescription
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onParent
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeLeft
+import androidx.compose.ui.test.swipeRight
+import org.junit.Rule
+import org.junit.Test
+
+class SwipeableListsTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun albumsAreListedOnFirstComposition() {
+ composeTestRule.setContent { SwipeableLists() }
+
+ composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Havana").assertIsDisplayed()
+ // Two albums in the sample data are by Ed Sheeran, so the artist alone is not unique.
+ composeTestRule.onAllNodesWithText("Ed Sheeran").onFirst().assertIsDisplayed()
+ }
+
+ @Test
+ fun everyRowExposesItsOverflowButtonToAccessibilityServices() {
+ composeTestRule.setContent { SwipeableLists() }
+
+ composeTestRule.onAllNodesWithContentDescription("More options").onFirst().assertIsDisplayed()
+ }
+
+ @Test
+ fun swipingEndToStartDeletesTheRowAndOffersUndo() {
+ composeTestRule.setContent { SwipeableLists() }
+
+ // The swipe must be dispatched to the row, not to the song title: the title node is only a
+ // few pixels wide, well short of the box's 50% positional threshold.
+ composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeLeft() }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Perfect").assertDoesNotExist()
+ composeTestRule.onNodeWithText("Perfect deleted").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Undo").assertIsDisplayed()
+ }
+
+ @Test
+ fun swipingStartToEndArchivesTheRowAndOffersUndo() {
+ composeTestRule.setContent { SwipeableLists() }
+
+ composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeRight() }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Perfect").assertDoesNotExist()
+ composeTestRule.onNodeWithText("Perfect archived").assertIsDisplayed()
+ }
+
+ @Test
+ fun undoRestoresASwipedAwayRow() {
+ composeTestRule.setContent { SwipeableLists() }
+
+ composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeLeft() }
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Undo").performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshListTest.kt b/app/src/androidTest/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshListTest.kt
new file mode 100644
index 00000000..3a221bd8
--- /dev/null
+++ b/app/src/androidTest/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshListTest.kt
@@ -0,0 +1,52 @@
+package com.guru.composecookbook.ui.home.pullrefreshdemos
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+
+class PullRefreshListTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun firstAlbumIsDisplayedInitially() {
+ composeTestRule.setContent { PullRefreshList(onPullRefresh = {}) }
+
+ composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
+ // Two albums in the sample data are by Ed Sheeran, so this row text is not unique.
+ composeTestRule
+ .onAllNodesWithText("Ed Sheeran, Album by Ed Sheeran-2016")
+ .onFirst()
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun refreshedAlbumIsAbsentBeforeAnyRefresh() {
+ composeTestRule.setContent { PullRefreshList(onPullRefresh = {}) }
+
+ // AlbumsDataProvider.album is only prepended by a refresh, so it must not be there yet.
+ // Match on the artist/description line: it is the one string unique to that album.
+ composeTestRule.onNodeWithText("Adele, Album by Adele-2016").assertDoesNotExist()
+ }
+
+ @Test
+ fun pullingDownInvokesTheRefreshCallback() {
+ var invocations = 0
+ composeTestRule.setContent { PullRefreshList(onPullRefresh = { invocations++ }) }
+
+ // The gesture has to travel far enough to cross PullToRefreshBox's threshold, so it is
+ // dispatched to the whole screen rather than to a single row.
+ composeTestRule.onRoot().performTouchInput { swipeDown() }
+ composeTestRule.waitForIdle()
+
+ assertEquals(1, invocations)
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f7adee5..3faf6874 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,10 @@
android:name=".ui.templates.TemplatesActivity"
android:label="@string/title_activity_templates"
android:theme="@style/Theme.ComposeCookBook.NoActionBar" />
+
{
DynamicUIActivity.newIntent(context, DynamicUiType.MOTIONLAYOUT.name, isDarkTheme)
}
+ HomeScreenItems.AdaptiveUI -> {
+ AdaptiveUIActivity.newIntent(context, isDarkTheme)
+ }
}
context.startActivity(intent)
}
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivity.kt
new file mode 100644
index 00000000..e6aa443d
--- /dev/null
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/adaptive/AdaptiveUIActivity.kt
@@ -0,0 +1,184 @@
+package com.guru.composecookbook.ui.home.adaptive
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Album
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.guru.composecookbook.data.AlbumsDataProvider
+import com.guru.composecookbook.data.model.Album
+import com.guru.composecookbook.theme.ComposeCookBookMaterial3Theme
+import com.guru.composecookbook.ui.home.dynamic.DynamicUIActivity
+
+/**
+ * Demonstrates adaptive layouts for large screens:
+ * - [NavigationSuiteScaffold] automatically switches between a bottom bar (compact), navigation
+ * rail (medium) and drawer (expanded) based on the window size class.
+ * - [ListDetailPaneScaffold] shows a single pane on phones and a two-pane list/detail layout on
+ * tablets, foldables and desktops.
+ */
+class AdaptiveUIActivity : ComponentActivity() {
+
+ private val isDarkTheme: Boolean by lazy {
+ intent?.getBooleanExtra(DynamicUIActivity.DARK_THEME, false) ?: false
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { ComposeCookBookMaterial3Theme(isDarkTheme) { AdaptiveDemoScreen() } }
+ }
+
+ companion object {
+ fun newIntent(context: Context, isDarkTheme: Boolean) =
+ Intent(context, AdaptiveUIActivity::class.java).apply {
+ putExtra(DynamicUIActivity.DARK_THEME, isDarkTheme)
+ }
+ }
+}
+
+enum class AdaptiveDemoDestination(val label: String, val icon: ImageVector) {
+ Albums("Albums", Icons.Default.Album),
+ Favorites("Favorites", Icons.Default.Favorite),
+ Profile("Profile", Icons.Default.Person),
+}
+
+@Composable
+fun AdaptiveDemoScreen() {
+ var currentDestination by remember { mutableStateOf(AdaptiveDemoDestination.Albums) }
+
+ NavigationSuiteScaffold(
+ navigationSuiteItems = {
+ AdaptiveDemoDestination.entries.forEach { destination ->
+ item(
+ icon = { Icon(destination.icon, contentDescription = destination.label) },
+ label = { Text(destination.label) },
+ selected = destination == currentDestination,
+ onClick = { currentDestination = destination },
+ )
+ }
+ }
+ ) {
+ when (currentDestination) {
+ AdaptiveDemoDestination.Albums -> AlbumListDetail()
+ AdaptiveDemoDestination.Favorites -> WindowInfoPane("Favorites")
+ AdaptiveDemoDestination.Profile -> WindowInfoPane("Profile")
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun AlbumListDetail() {
+ val navigator = rememberListDetailPaneScaffoldNavigator()
+
+ BackHandler(navigator.canNavigateBack()) { navigator.navigateBack() }
+
+ ListDetailPaneScaffold(
+ directive = navigator.scaffoldDirective,
+ value = navigator.scaffoldValue,
+ listPane = {
+ AnimatedPane {
+ LazyColumn {
+ items(AlbumsDataProvider.albums) { album ->
+ ListItem(
+ modifier =
+ Modifier.fillMaxWidth().clickable {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, album)
+ },
+ headlineContent = { Text(album.song) },
+ supportingContent = { Text(album.artist) },
+ leadingContent = { Icon(Icons.Default.Album, contentDescription = null) },
+ trailingContent = { Text(album.genre, style = MaterialTheme.typography.labelSmall) },
+ tonalElevation = 2.dp,
+ )
+ androidx.compose.material3.HorizontalDivider()
+ }
+ }
+ }
+ },
+ detailPane = {
+ AnimatedPane {
+ val album = navigator.currentDestination?.content
+ AlbumDetailPane(album)
+ }
+ },
+ )
+}
+
+@Composable
+private fun AlbumDetailPane(album: Album?) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ if (album == null) {
+ Text(
+ text = "Select an album from the list",
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(16.dp),
+ )
+ } else {
+ Card(modifier = Modifier.padding(16.dp)) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(text = album.song, style = MaterialTheme.typography.headlineMedium)
+ Text(text = album.artist, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = album.descriptions,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WindowInfoPane(title: String) {
+ val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(text = title, style = MaterialTheme.typography.headlineMedium)
+ Text(
+ text = "Width size class: ${windowSizeClass.windowWidthSizeClass}",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ Text(
+ text = "Height size class: ${windowSizeClass.windowHeightSizeClass}",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AdvanceListsActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AdvanceListsActivity.kt
index b89d5438..ffd0c8b7 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AdvanceListsActivity.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AdvanceListsActivity.kt
@@ -16,8 +16,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -49,7 +49,7 @@ class AdvanceListsActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme
Scaffold(
topBar = {
- SmallTopAppBar(
+ TopAppBar(
title = { Text(text = "Advance Lists(In Progress)") },
navigationIcon = {
IconButton(onClick = { onBackPressed() }) {
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AnimatedLists.kt b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AnimatedLists.kt
index 6db2af23..fb10a504 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AnimatedLists.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/AnimatedLists.kt
@@ -32,7 +32,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.data.DemoDataProvider
import com.guru.composecookbook.data.model.Tweet
import com.guru.composecookbook.theme.typography
@@ -145,8 +145,8 @@ fun AnimatedListItem(tweet: Tweet, itemIndex: Int, animationIndex: Int) {
Row(modifier = animatedModifier, verticalAlignment = Alignment.CenterVertically) {
Image(
painter =
- rememberImagePainter(
- data =
+ rememberAsyncImagePainter(
+ model =
"https://picsum.photos/id/${
itemIndex +
1
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/SwipeableLists.kt b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/SwipeableLists.kt
index 3cf707f2..0d4cbb66 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/SwipeableLists.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/advancelists/SwipeableLists.kt
@@ -1,71 +1,129 @@
package com.guru.composecookbook.ui.home.advancelists
-import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.guru.composecookbook.data.AlbumsDataProvider
import com.guru.composecookbook.data.model.Album
+import kotlinx.coroutines.launch
+/**
+ * Demonstrates Material3 [SwipeToDismissBox]:
+ * - swipe left (end to start) to delete an item,
+ * - swipe right (start to end) to archive it,
+ * - either action removes the item and offers Undo via a [Snackbar].
+ */
@Composable
fun SwipeableLists() {
- val albums by remember { mutableStateOf(AlbumsDataProvider.albums) }
- LazyColumn {
- itemsIndexed(
- items = albums,
- itemContent = { index, album -> SwipeableListItem(index, album) { index -> } },
- )
+ val albums = remember { AlbumsDataProvider.albums.toMutableStateList() }
+ val snackbarHostState = remember { SnackbarHostState() }
+ val listState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(state = listState) {
+ items(items = albums, key = { it.id }) { album ->
+ SwipeableListItem(
+ album = album,
+ onItemSwiped = { dismissValue ->
+ val index = albums.indexOf(album)
+ if (index >= 0) {
+ albums.removeAt(index)
+ val action =
+ if (dismissValue == SwipeToDismissBoxValue.StartToEnd) "archived" else "deleted"
+ scope.launch {
+ val result =
+ snackbarHostState.showSnackbar(
+ message = "${album.song} $action",
+ actionLabel = "Undo",
+ duration = SnackbarDuration.Short,
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ val restoreAt = index.coerceAtMost(albums.size)
+ albums.add(restoreAt, album)
+ // LazyColumn keeps the first visible item anchored, so a row re-inserted above
+ // it would silently land off-screen. Scroll it back into view.
+ listState.animateScrollToItem(restoreAt)
+ }
+ }
+ }
+ },
+ )
+ }
+ }
+ SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
}
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
-fun SwipeableListItem(index: Int, album: Album, onItemSwiped: (Int) -> Unit) {
+fun SwipeableListItem(album: Album, onItemSwiped: (SwipeToDismissBoxValue) -> Unit) {
val dismissState =
rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
- if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
- onItemSwiped(index)
+ if (dismissValue != SwipeToDismissBoxValue.Settled) {
+ onItemSwiped(dismissValue)
true
} else {
false
}
- }
+ },
+ // require half of the item width to be dragged before dismissing
+ positionalThreshold = { totalDistance -> totalDistance * 0.5f },
)
SwipeToDismissBox(
state = dismissState,
- backgroundContent = {
- Box(
- modifier =
- Modifier.fillMaxSize()
- .background(MaterialTheme.colorScheme.errorContainer)
- .padding(horizontal = 16.dp),
- contentAlignment = Alignment.CenterEnd,
- ) {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = "Delete",
- tint = MaterialTheme.colorScheme.onErrorContainer,
- )
- }
- },
+ backgroundContent = { SwipeActionBackground(dismissState.dismissDirection) },
content = { ListItemContent(album) },
- enableDismissFromStartToEnd = false,
+ enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
)
}
+@Composable
+private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
+ val isArchive = direction == SwipeToDismissBoxValue.StartToEnd
+ val background =
+ if (isArchive) MaterialTheme.colorScheme.secondaryContainer
+ else MaterialTheme.colorScheme.errorContainer
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(background)
+ .padding(horizontal = 16.dp),
+ contentAlignment = if (isArchive) Alignment.CenterStart else Alignment.CenterEnd,
+ ) {
+ if (isArchive) {
+ Icon(
+ imageVector = Icons.Default.Archive,
+ contentDescription = "Archive",
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+ }
+}
+
@Composable
private fun ListItemContent(album: Album) {
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/customfling/FlingListActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/customfling/FlingListActivity.kt
index 88adf82e..cb7e5f7f 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/customfling/FlingListActivity.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/customfling/FlingListActivity.kt
@@ -18,8 +18,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
@@ -139,7 +139,7 @@ fun ListViewContent(
*/
Scaffold(
topBar = {
- SmallTopAppBar(
+ TopAppBar(
title = {
Column(modifier = Modifier.padding(4.dp)) {
/*
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/dialogs/DialogsActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/dialogs/DialogsActivity.kt
index e53afdc9..10eb1bd6 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/dialogs/DialogsActivity.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/dialogs/DialogsActivity.kt
@@ -24,9 +24,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,7 +77,7 @@ class DialogsActivity : AppCompatActivity() {
fun DialogScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
- SmallTopAppBar(
+ TopAppBar(
title = { Text(text = "Dialogs") },
navigationIcon = {
IconButton(onClick = onBack) {
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/dynamic/DynamicUIActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/dynamic/DynamicUIActivity.kt
index fcc3f87b..44ef743f 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/dynamic/DynamicUIActivity.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/dynamic/DynamicUIActivity.kt
@@ -13,8 +13,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -64,7 +64,7 @@ class DynamicUIActivity : ComponentActivity() {
fun DynamicUiWrapper(uiType: String, onback: () -> Unit) {
Scaffold(
topBar = {
- SmallTopAppBar(
+ TopAppBar(
title = { Text(text = uiType) },
navigationIcon = {
IconButton(onClick = onback) {
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/lists/ListViewActivity.kt b/app/src/main/java/com/guru/composecookbook/ui/home/lists/ListViewActivity.kt
index 45c1f9f2..59022f45 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/lists/ListViewActivity.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/lists/ListViewActivity.kt
@@ -21,8 +21,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -73,7 +73,7 @@ fun BaseView(isDarkTheme: Boolean, content: @Composable () -> Unit) {
fun ListViewContent(listType: String, onBack: () -> Unit) {
Scaffold(
topBar = {
- SmallTopAppBar(
+ TopAppBar(
title = {
Column(modifier = Modifier.padding(4.dp)) {
Text(text = "ListView")
diff --git a/app/src/main/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshList.kt b/app/src/main/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshList.kt
index c68f1b01..1438fdc5 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshList.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/home/pullrefreshdemos/PullRefreshList.kt
@@ -1,69 +1,53 @@
package com.guru.composecookbook.ui.home.pullrefreshdemos
-import androidx.compose.animation.core.Animatable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.runtime.toMutableStateList
+import androidx.compose.ui.Modifier
import com.guru.composecookbook.data.AlbumsDataProvider
+import com.guru.composecookbook.spotify.ui.details.components.SpotifySongListItem
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+/**
+ * Demonstrates Material3 [PullToRefreshBox]: pull the list down to trigger a simulated network
+ * refresh (1.5s delay) that prepends a "new" album to the list.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullRefreshList(onPullRefresh: () -> Unit) {
- val albums = AlbumsDataProvider.albums
- // TODO revisit pull refresh
- val initialYTranslate = -100f
- val maximumYTranslate = 200f
- val animatedProgress = remember { Animatable(0f) }
+ val albums = remember { AlbumsDataProvider.albums.toMutableStateList() }
+ var isRefreshing by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
- // val draggableModifier = Modifier.draggable(
- // orientation = Orientation.Vertical,
- // reverseDirection = false,
- // enabled = lazyListState.firstVisibleItemIndex < 2,
- // onDragStarted = {
- // onPullRefresh.invoke()
- // if (animatedProgress.value < maximumYTranslate) {
- // animatedProgress.animateTo(
- // targetValue = maximumYTranslate,
- // animationSpec = tween(durationMillis = 1200, easing = LinearEasing),
- // )
- // }
- // },
- // onDragStopped = {
- // animatedProgress.animateTo(
- // targetValue = 3000f,
- // animationSpec = repeatable(
- // iterations = 1,
- // animation = tween(durationMillis = 3000, easing = LinearEasing),
- // ),
- // )
- // }
- // )
-
- // Box(modifier = draggableModifier) {
- // LazyColumn(state = lazyListState) {
- // items(
- // count = albums.size,
- // itemContent = { index ->
- // SpotifySongListItem(album = albums[index])
- // })
- // }
- // //Animated Icon
- // Icon(
- // imageVector = Icons.Default.RotateRight,
- // tint = Color.Black,
- // contentDescription = null,
- // modifier = Modifier.align(Alignment.TopCenter)
- // .graphicsLayer(
- // translationY = animateFloatAsState(
- // animatedProgress.value.coerceIn(
- // initialYTranslate,
- // maximumYTranslate
- // )
- // ).value,
- // rotationZ = animateFloatAsState(
- // animatedProgress.value
- // ).value
- // ).background(Color.LightGray, shape = CircleShape).padding(2.dp)
- // )
- // }
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = {
+ onPullRefresh.invoke()
+ scope.launch {
+ isRefreshing = true
+ // Simulate a network reload
+ delay(1500)
+ albums.add(0, AlbumsDataProvider.album.copy(id = albums.size + 1))
+ isRefreshing = false
+ lazyListState.scrollToItem(0)
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) {
+ items(albums) { album -> SpotifySongListItem(album = album) }
+ }
+ }
}
diff --git a/app/src/main/java/com/guru/composecookbook/ui/learnwidgets/AppBars.kt b/app/src/main/java/com/guru/composecookbook/ui/learnwidgets/AppBars.kt
index ef1e7752..cb496194 100644
--- a/app/src/main/java/com/guru/composecookbook/ui/learnwidgets/AppBars.kt
+++ b/app/src/main/java/com/guru/composecookbook/ui/learnwidgets/AppBars.kt
@@ -25,8 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar as Material3TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -57,7 +57,7 @@ fun AppBars() {
fun TopAppBarsDemo() {
SubtitleText(subtitle = "Top App bar")
- SmallTopAppBar(
+ Material3TopAppBar(
title = { Text(text = "Home") },
navigationIcon = {
IconButton(onClick = {}) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 935e3c3a..2ee3bb25 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,6 +17,7 @@
ca-app-pub-3940256099942544/6300978111
ca-app-pub-5207778306358129~3940384518
My Compose App
+ Adaptive UI
Custom Fling
Custom Fling Settings
Templates
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 1f16e886..b7ecc8c3 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -39,7 +39,10 @@ dependencies {
// in order to be able to apply "org.jetbrains.kotlin.plugin.compose" in the common scripts
implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.20")
- // in order to be able to apply "com.google.devtools.ksp" in the common scripts
+ // in order to be able to apply "org.jetbrains.kotlin.plugin.serialization" in module scripts
+ implementation("org.jetbrains.kotlin:kotlin-serialization:2.1.20")
+
+ // in order to be able to apply "com.google.devtools.ksp" in the common scripts
// (KSP version must match the Kotlin version above)
implementation("com.google.devtools.ksp:symbol-processing-gradle-plugin:2.1.20-1.0.31")
diff --git a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Dependencies.kt b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Dependencies.kt
index c2c63dd6..80d733a0 100644
--- a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Dependencies.kt
@@ -17,19 +17,25 @@ object Dependencies {
// Removed googleMaps = "com.google.android.libraries.maps:maps" as it's not publicly available
const val playServicesMaps =
"com.google.android.gms:play-services-maps:${Versions.playServicesMaps}"
- const val composeUi = "androidx.compose.ui:ui:${Versions.compose}"
- const val composeUiTooling = "androidx.compose.ui:ui-tooling:${Versions.compose}"
+ // Compose BOM keeps all androidx.compose artifacts below at compatible versions
+ const val composeBom = "androidx.compose:compose-bom:${Versions.composeBom}"
+ const val composeUi = "androidx.compose.ui:ui"
+ const val composeUiTooling = "androidx.compose.ui:ui-tooling"
// Material Design 1 and 3 dependencies
- const val composeMaterial = "androidx.compose.material:material:${Versions.compose}"
- const val composeMaterial3 = "androidx.compose.material3:material3:${Versions.material3}"
- const val composeMaterial3WindowSizeClass = "androidx.compose.material3:material3-window-size-class:${Versions.material3}"
- const val composeMaterialIconsExtended =
- "androidx.compose.material:material-icons-extended:${Versions.compose}"
-
+ const val composeMaterial = "androidx.compose.material:material"
+ const val composeMaterial3 = "androidx.compose.material3:material3"
+ const val composeMaterial3WindowSizeClass = "androidx.compose.material3:material3-window-size-class"
+ const val composeMaterial3Adaptive = "androidx.compose.material3.adaptive:adaptive"
+ const val composeMaterial3AdaptiveLayout = "androidx.compose.material3.adaptive:adaptive-layout"
+ const val composeMaterial3AdaptiveNavigation =
+ "androidx.compose.material3.adaptive:adaptive-navigation"
+ const val composeMaterial3AdaptiveNavigationSuite =
+ "androidx.compose.material3:material3-adaptive-navigation-suite"
+ const val composeMaterialIconsExtended = "androidx.compose.material:material-icons-extended"
+
// Runtime and State Management
- const val composeRuntime = "androidx.compose.runtime:runtime:${Versions.compose}"
- const val composeRuntimeLivedata =
- "androidx.compose.runtime:runtime-livedata:${Versions.compose}"
+ const val composeRuntime = "androidx.compose.runtime:runtime"
+ const val composeRuntimeLivedata = "androidx.compose.runtime:runtime-livedata"
// Layout and Navigation
const val composeConstraintLayout =
@@ -41,8 +47,8 @@ object Dependencies {
const val composeLottie = "com.airbnb.android:lottie-compose:${Versions.lottie}"
const val composeNavigation = "androidx.navigation:navigation-compose:${Versions.navCompose}"
- const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:${Versions.compose}"
- const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest:${Versions.compose}"
+ const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4"
+ const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest"
/**
* Custom fling behaviour
@@ -52,11 +58,13 @@ object Dependencies {
const val androidPagingRuntime = "androidx.paging:paging-runtime-ktx:${Versions.paging}"
const val lottie = "com.airbnb.android:lottie:${Versions.lottie}"
- const val androidExoPlayer = "com.google.android.exoplayer:exoplayer:${Versions.exoplayer}"
+ const val media3Exoplayer = "androidx.media3:media3-exoplayer:${Versions.media3}"
+ const val media3Ui = "androidx.media3:media3-ui:${Versions.media3}"
const val roomRuntime = "androidx.room:room-runtime:${Versions.room}"
const val roomKtx = "androidx.room:room-ktx:${Versions.room}"
const val roomCompiler = "androidx.room:room-compiler:${Versions.room}"
- const val coilCompose = "io.coil-kt:coil-compose:${Versions.coilCompose}"
+ const val coilCompose = "io.coil-kt.coil3:coil-compose:${Versions.coilCompose}"
+ const val coilNetworkOkHttp = "io.coil-kt.coil3:coil-network-okhttp:${Versions.coilCompose}"
const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}"
const val okHttpLoggingInterceptor =
"com.squareup.okhttp3:logging-interceptor:${Versions.loggingInterceptor}"
@@ -67,6 +75,8 @@ object Dependencies {
"org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
const val coroutinesAndroid =
"org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}"
+ const val kotlinxSerializationJson =
+ "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinxSerializationJson}"
const val viewModelKtx =
"androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.androidLifecycleGrouped}"
const val liveDataKtx =
diff --git a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/DependencyHandlerExtensions.kt b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/DependencyHandlerExtensions.kt
index 54a15de4..d5f3ff9d 100644
--- a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/DependencyHandlerExtensions.kt
+++ b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/DependencyHandlerExtensions.kt
@@ -2,7 +2,13 @@ package com.guru.composecookbook.build.dependencies
import org.gradle.api.artifacts.dsl.DependencyHandler
+/** Imports the Compose BOM so the unversioned Compose artifacts resolve consistently. */
+private fun DependencyHandler.addComposeBom(configurationName: String) {
+ add(configurationName, platform(Dependencies.composeBom))
+}
+
fun DependencyHandler.addComposeOfficialDependencies() {
+ addComposeBom("implementation")
composeOfficialDependencies.forEach {
add("implementation", it)
}
@@ -15,6 +21,7 @@ fun DependencyHandler.addComposeThirdPartyDependencies() {
}
fun DependencyHandler.addComposeDebugDependencies() {
+ addComposeBom("debugImplementation")
composeDebugDependencies.forEach {
add("debugImplementation", it)
}
@@ -33,6 +40,7 @@ fun DependencyHandler.addKotlinTestDependencies() {
}
fun DependencyHandler.addDataDependencies() {
+ addComposeBom("implementation")
add("ksp", Dependencies.roomCompiler)
dataDependencies.forEach {
add("implementation", it)
@@ -76,6 +84,7 @@ fun DependencyHandler.addThirdPartyUnitTestsDependencies() {
}
fun DependencyHandler.addAndroidInstrumentationTestsDependencies() {
+ addComposeBom("androidTestImplementation")
androidInstrumentationTestsDependencies.forEach {
add("androidTestImplementation", it)
}
diff --git a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/GroupedDependencies.kt b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/GroupedDependencies.kt
index 8cd6ff23..03d8d9cb 100644
--- a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/GroupedDependencies.kt
+++ b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/GroupedDependencies.kt
@@ -13,6 +13,10 @@ internal val composeOfficialDependencies = listOf(
Dependencies.composeActivity,
Dependencies.composeNavigation,
Dependencies.composeRuntime,
+ Dependencies.composeMaterial3Adaptive,
+ Dependencies.composeMaterial3AdaptiveLayout,
+ Dependencies.composeMaterial3AdaptiveNavigation,
+ Dependencies.composeMaterial3AdaptiveNavigationSuite,
)
internal val composeThirdPartyDependencies = listOf(
@@ -30,7 +34,8 @@ internal val composeDebugDependencies = listOf(
internal val kotlinDependencies = listOf(
Dependencies.kotlinStandardLibraryJdk8,
Dependencies.coroutinesCore,
- Dependencies.coroutinesAndroid
+ Dependencies.coroutinesAndroid,
+ Dependencies.kotlinxSerializationJson
)
internal val kotlinTestDependencies = listOf(
@@ -62,7 +67,8 @@ internal val coreAndroidUiDependencies = listOf(
)
internal val googleAndroidLibraries = listOf(
- Dependencies.androidExoPlayer,
+ Dependencies.media3Exoplayer,
+ Dependencies.media3Ui,
Dependencies.playServicesAds,
Dependencies.playServicesMaps
// Removed Dependencies.googleMaps as it's not available
@@ -93,6 +99,7 @@ internal val androidInstrumentationTestsDependencies = listOf(
internal val thirdPartyUiDependencies = listOf(
Dependencies.coilCompose,
+ Dependencies.coilNetworkOkHttp,
Dependencies.lottie,
Dependencies.composeLottie
)
diff --git a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Versions.kt b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Versions.kt
index 270f8411..1ca7ff65 100644
--- a/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Versions.kt
+++ b/buildSrc/src/main/kotlin/com/guru/composecookbook/build/dependencies/Versions.kt
@@ -1,16 +1,17 @@
package com.guru.composecookbook.build.dependencies
internal object Versions {
- const val compose = "1.6.1" // Latest stable Compose UI version
+ const val composeBom = "2024.12.01" // Compose BOM maps ui/material/runtime etc. versions
const val activityCompose = "1.8.2" // Latest stable
const val pagingCompose = "3.2.1" // Latest stable
- const val navCompose = "2.7.7" // Latest stable navigation
+ const val navCompose = "2.8.9" // Stable navigation with type-safe routes
const val constraintLayoutCompose = "1.1.0" // Latest stable
const val fontAwesomeCompose = "1.1.0" // Keeping as is since it's a third-party lib
- const val coilCompose = "2.5.0" // Latest stable
+ const val coilCompose = "3.1.0" // Coil 3.x (multiplatform)
const val kotlin = "2.1.20" // Kotlin 2.x with the K2 compiler
const val coroutines = "1.7.3" // Latest stable
- const val androidLifecycleGrouped = "2.7.0" // Latest stable
+ const val kotlinxSerializationJson = "1.8.0" // For type-safe navigation routes
+ const val androidLifecycleGrouped = "2.8.7" // Latest stable 2.8.x
const val flinger = "1.0.5" // Keeping as is since it's a third-party lib
const val paging = "3.2.1" // Latest stable
const val lottie = "6.3.0" // Latest stable
@@ -19,11 +20,10 @@ internal object Versions {
const val appcompat = "1.6.1" // Latest stable
const val paletteKtx = "1.0.0" // Latest stable
const val material = "1.11.0" // Latest stable Material Design 1
- const val material3 = "1.2.1" // Latest stable Material 3
const val playServicesAds = "22.5.0" // Latest stable
const val googleMaps = "2.0.1" // Latest Google Maps version
const val playServicesMaps = "18.2.0" // Latest stable
- const val exoplayer = "2.19.1" // Latest stable
+ const val media3 = "1.5.1" // AndroidX Media3 (ExoPlayer successor)
const val loggingInterceptor = "4.12.0" // Latest stable
const val retrofit = "2.9.0" // Latest stable
const val gson = "2.10.1" // Latest stable
diff --git a/buildSrc/src/main/kotlin/common-compose-module-configs-script-plugin.gradle.kts b/buildSrc/src/main/kotlin/common-compose-module-configs-script-plugin.gradle.kts
index e537fbae..f2389f7c 100644
--- a/buildSrc/src/main/kotlin/common-compose-module-configs-script-plugin.gradle.kts
+++ b/buildSrc/src/main/kotlin/common-compose-module-configs-script-plugin.gradle.kts
@@ -12,6 +12,7 @@ android {
defaultConfig {
minSdk = ProjectConfigs.minSdkVersion
targetSdk = ProjectConfigs.targetSdkVersion
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
diff --git a/components/carousel/build.gradle.kts b/components/carousel/build.gradle.kts
index e2bc39ef..831292df 100644
--- a/components/carousel/build.gradle.kts
+++ b/components/carousel/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.guru.composecookbook.build.dependencies.addAndroidInstrumentationTestsDependencies
import com.guru.composecookbook.build.dependencies.addComposeOfficialDependencies
plugins {
@@ -12,4 +13,6 @@ dependencies {
implementation(project(":data"))
addComposeOfficialDependencies()
+
+ addAndroidInstrumentationTestsDependencies()
}
diff --git a/components/carousel/src/androidTest/java/com/guru/composecookbook/carousel/InfiniteCarouselTest.kt b/components/carousel/src/androidTest/java/com/guru/composecookbook/carousel/InfiniteCarouselTest.kt
new file mode 100644
index 00000000..f5124fdf
--- /dev/null
+++ b/components/carousel/src/androidTest/java/com/guru/composecookbook/carousel/InfiniteCarouselTest.kt
@@ -0,0 +1,75 @@
+package com.guru.composecookbook.carousel
+
+import androidx.compose.material3.Text
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeLeft
+import androidx.compose.ui.test.swipeRight
+import com.guru.composecookbook.data.DemoDataProvider
+import org.junit.Rule
+import org.junit.Test
+
+class InfiniteCarouselTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ private val items = DemoDataProvider.itemList.take(3)
+
+ // Auto advance is effectively disabled so the test controls paging deterministically.
+ private fun setCarouselContent() {
+ composeTestRule.setContent {
+ InfiniteCarousel(items = items, autoAdvanceMillis = Long.MAX_VALUE) { item ->
+ Text(text = item.title)
+ }
+ }
+ }
+
+ @Test
+ fun firstItemIsDisplayedInitially() {
+ setCarouselContent()
+
+ composeTestRule.onNodeWithText(items[0].title).assertIsDisplayed()
+ }
+
+ @Test
+ fun swipingForwardLoopsThroughItems() {
+ setCarouselContent()
+
+ // The gesture must span the pager, not the narrow Text node inside the page.
+ composeTestRule.onRoot().performTouchInput { swipeLeft() }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(items[1].title).assertIsDisplayed()
+ }
+
+ @Test
+ fun swipingBackwardFromFirstItemShowsLastItem() {
+ setCarouselContent()
+
+ // Looping backwards past the first item must show the last item.
+ composeTestRule.onRoot().performTouchInput { swipeRight() }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText(items[2].title).assertIsDisplayed()
+ }
+
+ @Test
+ fun emptyListRendersNothingInsteadOfCrashing() {
+ // Composing at all is the real assertion: without the guard the page-index modulo
+ // divides by zero and this call throws before any assertion is reached.
+ composeTestRule.setContent {
+ InfiniteCarousel(items = emptyList(), autoAdvanceMillis = Long.MAX_VALUE) {
+ Text(text = PAGE_CONTENT)
+ }
+ }
+
+ composeTestRule.onNodeWithText(PAGE_CONTENT).assertDoesNotExist()
+ }
+
+ private companion object {
+ const val PAGE_CONTENT = "page content"
+ }
+}
diff --git a/components/carousel/src/main/java/com/guru/composecookbook/carousel/CarouselLayout.kt b/components/carousel/src/main/java/com/guru/composecookbook/carousel/CarouselLayout.kt
index 807ab9ae..cbb25524 100644
--- a/components/carousel/src/main/java/com/guru/composecookbook/carousel/CarouselLayout.kt
+++ b/components/carousel/src/main/java/com/guru/composecookbook/carousel/CarouselLayout.kt
@@ -38,6 +38,15 @@ fun CarouselLayout() {
val pagerState: PagerState = run { remember { PagerState(1, 0, items.size - 1) } }
val selectedPage = remember { mutableStateOf(2) }
+ Text(
+ text = "Infinite looping carousel (auto advance)",
+ style = typography.body2,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
+ )
+ InfiniteCarousel(items = items, modifier = Modifier.height(230.dp)) { item ->
+ CarouselItem(item)
+ }
+
PrepareFirstPager(pagerState, items, selectedPage)
PrepareSecondPager(pagerState, items, selectedPage)
PrepareThirdPager(pagerState, items, selectedPage)
diff --git a/components/carousel/src/main/java/com/guru/composecookbook/carousel/InfiniteCarousel.kt b/components/carousel/src/main/java/com/guru/composecookbook/carousel/InfiniteCarousel.kt
new file mode 100644
index 00000000..db025eeb
--- /dev/null
+++ b/components/carousel/src/main/java/com/guru/composecookbook/carousel/InfiniteCarousel.kt
@@ -0,0 +1,67 @@
+package com.guru.composecookbook.carousel
+
+import androidx.compose.foundation.interaction.collectIsDraggedAsState
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lens
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.guru.composecookbook.data.model.Item
+import kotlinx.coroutines.delay
+
+/**
+ * An infinitely looping carousel built on top of foundation's [HorizontalPager].
+ *
+ * The pager is given a very large virtual page count and starts in the middle, so the user can
+ * swipe endlessly in both directions; the virtual page index is mapped back onto the real items
+ * with a modulo. While idle it auto-advances every [autoAdvanceMillis]; auto-advance pauses while
+ * the user is dragging.
+ */
+@Composable
+fun InfiniteCarousel(
+ items: List- ,
+ modifier: Modifier = Modifier,
+ autoAdvanceMillis: Long = 3000L,
+ content: @Composable (Item) -> Unit,
+) {
+ // Nothing to page through: render nothing rather than dividing by zero below.
+ if (items.isEmpty()) return
+
+ // Start in the middle of the virtual range, aligned to the first real item.
+ val virtualCount = Int.MAX_VALUE
+ val startPage = remember(items) { virtualCount / 2 - ((virtualCount / 2) % items.size) }
+ val pagerState = rememberPagerState(initialPage = startPage) { virtualCount }
+ val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
+
+ // Auto advance while the user is not interacting with the pager.
+ LaunchedEffect(isDragged, items) {
+ if (!isDragged) {
+ while (true) {
+ delay(autoAdvanceMillis)
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ }
+ }
+
+ Column(modifier = modifier) {
+ HorizontalPager(state = pagerState) { page -> content(items[page % items.size]) }
+ Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
+ val selectedIndex = pagerState.currentPage % items.size
+ items.forEachIndexed { index, _ ->
+ CarouselDot(
+ selected = index == selectedIndex,
+ color = MaterialTheme.colorScheme.primary,
+ icon = Icons.Filled.Lens,
+ )
+ }
+ }
+ }
+}
diff --git a/components/tags/build.gradle.kts b/components/tags/build.gradle.kts
index e5ba1509..b1ad0488 100644
--- a/components/tags/build.gradle.kts
+++ b/components/tags/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.guru.composecookbook.build.dependencies.addAndroidInstrumentationTestsDependencies
import com.guru.composecookbook.build.dependencies.addComposeOfficialDependencies
plugins {
@@ -11,4 +12,6 @@ dependencies {
implementation(project(":theme"))
addComposeOfficialDependencies()
+
+ addAndroidInstrumentationTestsDependencies()
}
diff --git a/components/tags/src/androidTest/java/com/guru/composecookbook/tags/InterestTagTest.kt b/components/tags/src/androidTest/java/com/guru/composecookbook/tags/InterestTagTest.kt
new file mode 100644
index 00000000..3d315b55
--- /dev/null
+++ b/components/tags/src/androidTest/java/com/guru/composecookbook/tags/InterestTagTest.kt
@@ -0,0 +1,39 @@
+package com.guru.composecookbook.tags
+
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class InterestTagTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun interestTagDisplaysItsText() {
+ composeTestRule.setContent { InterestTag(text = "Jetpack Compose") }
+
+ composeTestRule.onNodeWithText("Jetpack Compose").assertIsDisplayed()
+ }
+
+ @Test
+ fun interestTagIsClickable() {
+ composeTestRule.setContent { InterestTag(text = "Clickable tag") }
+
+ composeTestRule.onNodeWithText("Clickable tag").assertHasClickAction()
+ }
+
+ @Test
+ fun interestTagInvokesOnClick() {
+ var clicked = false
+ composeTestRule.setContent { InterestTag(text = "Tap me", onClick = { clicked = true }) }
+
+ composeTestRule.onNodeWithText("Tap me").performClick()
+
+ assertTrue(clicked)
+ }
+}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 4e206914..98e33820 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -6,5 +6,6 @@ plugins {
android { namespace = "com.guru.composecookbook.data" }
dependencies {
+ implementation(platform(com.guru.composecookbook.build.dependencies.Dependencies.composeBom))
implementation(com.guru.composecookbook.build.dependencies.Dependencies.composeRuntime)
}
diff --git a/data/src/main/java/com/guru/composecookbook/data/DemoDataProvider.kt b/data/src/main/java/com/guru/composecookbook/data/DemoDataProvider.kt
index f35390e2..327bb00f 100644
--- a/data/src/main/java/com/guru/composecookbook/data/DemoDataProvider.kt
+++ b/data/src/main/java/com/guru/composecookbook/data/DemoDataProvider.kt
@@ -346,6 +346,7 @@ object DemoDataProvider {
HomeScreenItems.TabLayout,
HomeScreenItems.BottomSheets,
HomeScreenItems.PullRefresh,
+ HomeScreenItems.AdaptiveUI,
)
const val longText =
diff --git a/data/src/main/java/com/guru/composecookbook/data/model/HomeScreenItems.kt b/data/src/main/java/com/guru/composecookbook/data/model/HomeScreenItems.kt
index e8303151..d1fb80ff 100644
--- a/data/src/main/java/com/guru/composecookbook/data/model/HomeScreenItems.kt
+++ b/data/src/main/java/com/guru/composecookbook/data/model/HomeScreenItems.kt
@@ -31,6 +31,8 @@ sealed class HomeScreenItems {
object MotionLayout : HomeScreenItems()
+ object AdaptiveUI : HomeScreenItems()
+
val name: String
get() =
when (this) {
@@ -49,5 +51,6 @@ sealed class HomeScreenItems {
AdvanceLists -> "Advance Lists"
PullRefresh -> "Pull refresh demos"
CustomFling -> "Custom Fling"
+ AdaptiveUI -> "Adaptive UI (Large screens)"
}
}
diff --git a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/composewear/AndroidWearActivity.kt b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/composewear/AndroidWearActivity.kt
index c87a1a1f..96c4f286 100644
--- a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/composewear/AndroidWearActivity.kt
+++ b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/composewear/AndroidWearActivity.kt
@@ -43,7 +43,7 @@ import androidx.wear.compose.material.rememberScalingLazyListState
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.charts.LineChart
import com.guru.composecookbook.cryptoapp.data.db.models.Crypto
import com.guru.composecookbook.cryptoapp.ui.home.CryptoHomeViewModel
@@ -129,7 +129,7 @@ fun WearStockListItem(crypto: Crypto, onItemSelected: (String) -> Unit) {
Card(onClick = { onItemSelected.invoke(crypto.symbol) }, shape = MaterialTheme.shapes.small) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Image(
- painter = rememberImagePainter(data = crypto.image),
+ painter = rememberAsyncImagePainter(model = crypto.image),
modifier = Modifier.size(32.dp).padding(4.dp),
contentDescription = null,
contentScale = ContentScale.Crop,
@@ -159,7 +159,7 @@ fun StockDetailScreen(crypto: Crypto) {
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
- painter = rememberImagePainter(data = crypto.image),
+ painter = rememberAsyncImagePainter(model = crypto.image),
modifier = Modifier.size(24.dp).padding(end = 8.dp),
contentDescription = null,
contentScale = ContentScale.Crop,
diff --git a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/CryptoDetailScreen.kt b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/CryptoDetailScreen.kt
index 25fe5324..417fab17 100644
--- a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/CryptoDetailScreen.kt
+++ b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/CryptoDetailScreen.kt
@@ -48,7 +48,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.charts.BarCharts
import com.guru.composecookbook.charts.LineChart
import com.guru.composecookbook.cryptoapp.R
@@ -102,7 +102,7 @@ fun CryptoTopSection(crypto: Crypto, scrollState: ScrollState) {
Row(modifier = Modifier.padding(top = 20.dp)) {
Text(text = crypto.name, style = typography.h6, modifier = Modifier.padding(end = 8.dp))
Image(
- painter = rememberImagePainter(data = crypto.image),
+ painter = rememberAsyncImagePainter(model = crypto.image),
modifier = Modifier.size(28.dp),
contentDescription = null,
)
diff --git a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/FavoriteCryptoCard.kt b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/FavoriteCryptoCard.kt
index 78f19924..7696f18c 100644
--- a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/FavoriteCryptoCard.kt
+++ b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/detail/components/FavoriteCryptoCard.kt
@@ -14,7 +14,7 @@ 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 coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.cryptoapp.data.CryptoDemoDataProvider
import com.guru.composecookbook.cryptoapp.data.db.models.Crypto
import com.guru.composecookbook.cryptoapp.ui.internal.extensions.roundToTwoDecimals
@@ -27,7 +27,7 @@ fun FavoriteCryptoCard(crypto: Crypto) {
Column(modifier = Modifier.padding(16.dp).width(120.dp).height(180.dp)) {
Row(modifier = Modifier.weight(1f)) {
Image(
- painter = rememberImagePainter(data = crypto.image),
+ painter = rememberAsyncImagePainter(model = crypto.image),
modifier = Modifier.size(24.dp),
contentDescription = null,
)
diff --git a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoHomeScreen.kt b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoHomeScreen.kt
index 6abeeac6..15b2b8e7 100644
--- a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoHomeScreen.kt
+++ b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoHomeScreen.kt
@@ -47,7 +47,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.cryptoapp.data.CryptoDemoDataProvider
import com.guru.composecookbook.cryptoapp.data.db.models.Crypto
import com.guru.composecookbook.cryptoapp.ui.home.CryptoHomeInteractionEvents
@@ -147,7 +147,7 @@ fun FavoriteItem(crypto: Crypto, openCryptoDetail: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
) {
Image(
- painter = rememberImagePainter(data = crypto.image),
+ painter = rememberAsyncImagePainter(model = crypto.image),
modifier = Modifier.size(24.dp),
contentDescription = null,
)
diff --git a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoListItem.kt b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoListItem.kt
index 8b4b1740..198dd235 100644
--- a/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoListItem.kt
+++ b/demos/cryptoapp/app/src/main/java/com/guru/composecookbook/cryptoapp/ui/home/components/CryptoListItem.kt
@@ -25,7 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
import com.guru.composecookbook.charts.LineChart
import com.guru.composecookbook.cryptoapp.data.CryptoDemoDataProvider
import com.guru.composecookbook.cryptoapp.data.db.models.Crypto
diff --git a/demos/datingapp/src/main/java/com/guru/composecookbook/datingapp/DatingHomeActivity.kt b/demos/datingapp/src/main/java/com/guru/composecookbook/datingapp/DatingHomeActivity.kt
index e37eb3d8..659b7501 100644
--- a/demos/datingapp/src/main/java/com/guru/composecookbook/datingapp/DatingHomeActivity.kt
+++ b/demos/datingapp/src/main/java/com/guru/composecookbook/datingapp/DatingHomeActivity.kt
@@ -21,8 +21,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -115,7 +115,7 @@ fun DatingHomeAppbar(navType: MutableState) {
DatingNavType.CHATS -> "Chats"
DatingNavType.PROFILE -> "My Profile"
}
- SmallTopAppBar(
+ TopAppBar(
title = { Text(title, style = typography.h6) },
actions = { IconButton(onClick = {}) { Icons.Default.MyLocation } },
)
diff --git a/demos/gmail/build.gradle.kts b/demos/gmail/build.gradle.kts
index 8007c68a..4073b635 100644
--- a/demos/gmail/build.gradle.kts
+++ b/demos/gmail/build.gradle.kts
@@ -1,9 +1,11 @@
import com.guru.composecookbook.build.dependencies.addComposeOfficialDependencies
import com.guru.composecookbook.build.dependencies.addCoreAndroidUiDependencies
+import com.guru.composecookbook.build.dependencies.addKotlinDependencies
plugins {
/** See [common-compose-module-configs-script-plugin.gradle.kts] file */
id("common-compose-module-configs-script-plugin")
+ id("org.jetbrains.kotlin.plugin.serialization")
}
android { namespace = "com.guru.composecookbook.gmail" }
@@ -13,6 +15,7 @@ dependencies {
implementation(project(":data"))
implementation(project(":theme"))
+ addKotlinDependencies()
addComposeOfficialDependencies()
addCoreAndroidUiDependencies()
}
diff --git a/demos/gmail/src/main/java/com/guru/composecookbook/gmail/ui/home/GmailHome.kt b/demos/gmail/src/main/java/com/guru/composecookbook/gmail/ui/home/GmailHome.kt
index c3010872..494fffb6 100644
--- a/demos/gmail/src/main/java/com/guru/composecookbook/gmail/ui/home/GmailHome.kt
+++ b/demos/gmail/src/main/java/com/guru/composecookbook/gmail/ui/home/GmailHome.kt
@@ -68,16 +68,24 @@ import com.guru.composecookbook.gmail.ui.details.MessageDetailScreen
import com.guru.composecookbook.theme.graySurface
import com.guru.composecookbook.theme.green500
import kotlin.math.absoluteValue
+import kotlinx.serialization.Serializable
+
+/** Type-safe navigation routes for the Gmail demo. */
+@Serializable object GmailHomeRoute
+
+@Serializable object GmailDetailRoute
+
+@Serializable object GmailCreateRoute
@Composable
fun GmailScreen() {
val navController = rememberNavController()
- NavHost(navController = navController, startDestination = "home") {
- composable("home") { GmailHome(navController = navController) }
+ NavHost(navController = navController, startDestination = GmailHomeRoute) {
+ composable { GmailHome(navController = navController) }
- composable("detail") { MessageDetailScreen(navController = navController) }
+ composable { MessageDetailScreen(navController = navController) }
- composable("create") { CreateMessageScreen(navController = navController) }
+ composable { CreateMessageScreen(navController = navController) }
}
}
@@ -157,7 +165,7 @@ fun IconWithBadge(badge: Int, icon: ImageVector, modifier: Modifier = Modifier)
fun GmailFloatingActionButton(navController: NavHostController, expandState: Boolean) {
FloatingActionButton(
- onClick = { navController.navigate("create") },
+ onClick = { navController.navigate(GmailCreateRoute) },
modifier = Modifier.padding(16.dp).height(48.dp).widthIn(min = 48.dp),
backgroundColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.primary,
@@ -241,14 +249,14 @@ fun GmailContent(
AnimatedVisibility(visible = visible.value) {
Box(modifier = Modifier.background(green500)) {
GmailListActionItems(modifier = Modifier.align(Alignment.CenterEnd))
- GmailListItem(it) { navController.navigate("detail") }
+ GmailListItem(it) { navController.navigate(GmailDetailRoute) }
}
}
}
}
SearchLayout(searchOffsetY.value, scaffoldState.drawerState, showUserDialog) {
- navController.navigate("create")
+ navController.navigate(GmailCreateRoute)
}
}
}
diff --git a/demos/instagram/src/main/java/com/guru/composecookbook/instagram/components/stories/StoryItem.kt b/demos/instagram/src/main/java/com/guru/composecookbook/instagram/components/stories/StoryItem.kt
index 3ee365fa..01e0e3d3 100644
--- a/demos/instagram/src/main/java/com/guru/composecookbook/instagram/components/stories/StoryItem.kt
+++ b/demos/instagram/src/main/java/com/guru/composecookbook/instagram/components/stories/StoryItem.kt
@@ -8,8 +8,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
-import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -59,7 +59,7 @@ fun StoryItem(
)
.clickable(
interactionSource = interactionSource,
- indication = rememberRipple(bounded = false, radius = ProfileSizes.large / 2),
+ indication = ripple(bounded = false, radius = ProfileSizes.large / 2),
enabled = true,
onClickLabel = null,
onClick = onClick,
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/MovieDetailActivity.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/MovieDetailActivity.kt
index 8d71bb93..4f29f0cb 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/MovieDetailActivity.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/MovieDetailActivity.kt
@@ -6,7 +6,7 @@ import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import coil.annotation.ExperimentalCoilApi
+import coil3.annotation.ExperimentalCoilApi
import com.guru.composecookbook.moviesapp.data.db.models.Movie
import com.guru.composecookbook.moviesapp.ui.details.components.MovieDetailContent
import com.guru.composecookbook.theme.ComposeCookBookTheme
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt
index 947c50e8..c7982651 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/MovieDetailContent.kt
@@ -1,118 +1,118 @@
-package com.guru.composecookbook.moviesapp.ui.details.components
-
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-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.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.Button
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.LibraryAdd
-import androidx.compose.runtime.Composable
-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.graphics.ImageBitmap
-import androidx.compose.ui.graphics.asAndroidBitmap
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.imageResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import coil.compose.AsyncImagePainter
-import coil.compose.rememberAsyncImagePainter
-import com.guru.composecookbook.moviesapp.data.db.models.Movie
-import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModel
-import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModelFactory
-import com.guru.composecookbook.theme.extensions.generateDominantColorState
-import com.guru.composecookbook.theme.graySurface
-import com.guru.composecookbook.theme.modifiers.verticalGradientBackground
-import com.guru.composecookbook.theme.typography
-
-@Composable
-fun MovieDetailContent(movie: Movie, imageId: Int) {
- val expand = remember { mutableStateOf(false) }
- val viewModel: MovieDetailViewModel =
- viewModel(factory = MovieDetailViewModelFactory(LocalContext.current))
- var dominantColors = listOf(graySurface, Color.Black)
-
- if (imageId != 0) {
- val context = LocalContext.current
- val currentBitmap = ImageBitmap.imageResource(context.resources, imageId)
-
- val swatch = currentBitmap.asAndroidBitmap().generateDominantColorState()
- dominantColors = listOf(Color(swatch.rgb), Color.Black)
- }
-
- LazyColumn(
- modifier =
- Modifier.verticalGradientBackground(dominantColors)
- .padding(animateDpAsState(if (expand.value) 1.dp else 120.dp, tween(350)).value)
- ) {
- item {
- val painter =
- rememberAsyncImagePainter(model = "https://image.tmdb.org/t/p/w500/${movie.poster_path}")
- Image(
- painter = painter,
- contentScale = ContentScale.Crop,
- contentDescription = null,
- modifier = Modifier.height(600.dp).fillMaxWidth(),
- )
- when (painter.state) {
- is AsyncImagePainter.State.Success -> expand.value = true
- else -> expand.value = false
- }
- }
- item {
- Column(modifier = Modifier.background(MaterialTheme.colors.onSurface)) {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(text = movie.title, modifier = Modifier.padding(8.dp), style = typography.h6)
- IconButton(onClick = {}) {
- Icon(
- imageVector = Icons.Default.LibraryAdd,
- contentDescription = null,
- tint = MaterialTheme.colors.primary,
- )
- }
- }
- GenreSection(viewModel, movie.genre_ids)
- Text(
- text = "Release: ${movie.release_date}",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
- style = typography.h6.copy(fontSize = 12.sp),
- )
- Text(
- text = "PG13 • ${movie.vote_average}/10",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
- style = typography.h6.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
- )
- Text(text = movie.overview, modifier = Modifier.padding(8.dp), style = typography.subtitle2)
- Spacer(modifier = Modifier.height(20.dp))
- SimilarMoviesSection(movie, viewModel)
- Spacer(modifier = Modifier.height(50.dp))
- Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
- Text(text = "Get Tickets", modifier = Modifier.padding(8.dp))
- }
- }
- }
- }
-}
+package com.guru.composecookbook.moviesapp.ui.details.components
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.Button
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LibraryAdd
+import androidx.compose.runtime.Composable
+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.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.imageResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil3.compose.AsyncImagePainter
+import coil3.compose.rememberAsyncImagePainter
+import com.guru.composecookbook.moviesapp.data.db.models.Movie
+import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModel
+import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModelFactory
+import com.guru.composecookbook.theme.extensions.generateDominantColorState
+import com.guru.composecookbook.theme.graySurface
+import com.guru.composecookbook.theme.modifiers.verticalGradientBackground
+import com.guru.composecookbook.theme.typography
+
+@Composable
+fun MovieDetailContent(movie: Movie, imageId: Int) {
+ val expand = remember { mutableStateOf(false) }
+ val viewModel: MovieDetailViewModel =
+ viewModel(factory = MovieDetailViewModelFactory(LocalContext.current))
+ var dominantColors = listOf(graySurface, Color.Black)
+
+ if (imageId != 0) {
+ val context = LocalContext.current
+ val currentBitmap = ImageBitmap.imageResource(context.resources, imageId)
+
+ val swatch = currentBitmap.asAndroidBitmap().generateDominantColorState()
+ dominantColors = listOf(Color(swatch.rgb), Color.Black)
+ }
+
+ LazyColumn(
+ modifier =
+ Modifier.verticalGradientBackground(dominantColors)
+ .padding(animateDpAsState(if (expand.value) 1.dp else 120.dp, tween(350)).value)
+ ) {
+ item {
+ val painter =
+ rememberAsyncImagePainter(model = "https://image.tmdb.org/t/p/w500/${movie.poster_path}")
+ Image(
+ painter = painter,
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ modifier = Modifier.height(600.dp).fillMaxWidth(),
+ )
+ when (painter.state) {
+ is AsyncImagePainter.State.Success -> expand.value = true
+ else -> expand.value = false
+ }
+ }
+ item {
+ Column(modifier = Modifier.background(MaterialTheme.colors.onSurface)) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = movie.title, modifier = Modifier.padding(8.dp), style = typography.h6)
+ IconButton(onClick = {}) {
+ Icon(
+ imageVector = Icons.Default.LibraryAdd,
+ contentDescription = null,
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+ }
+ GenreSection(viewModel, movie.genre_ids)
+ Text(
+ text = "Release: ${movie.release_date}",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = typography.h6.copy(fontSize = 12.sp),
+ )
+ Text(
+ text = "PG13 • ${movie.vote_average}/10",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = typography.h6.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
+ )
+ Text(text = movie.overview, modifier = Modifier.padding(8.dp), style = typography.subtitle2)
+ Spacer(modifier = Modifier.height(20.dp))
+ SimilarMoviesSection(movie, viewModel)
+ Spacer(modifier = Modifier.height(50.dp))
+ Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
+ Text(text = "Get Tickets", modifier = Modifier.padding(8.dp))
+ }
+ }
+ }
+ }
+}
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt
index 2da4e797..7da79a33 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/details/components/SimilarMoviesSection.kt
@@ -1,45 +1,47 @@
-package com.guru.composecookbook.moviesapp.ui.details.components
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.livedata.observeAsState
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.unit.dp
-import coil.compose.rememberImagePainter
-import com.guru.composecookbook.moviesapp.data.db.models.Movie
-import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModel
-import com.guru.composecookbook.theme.typography
-
-@Composable
-fun SimilarMoviesSection(currentMovie: Movie?, viewModel: MovieDetailViewModel) {
- viewModel.getSimilarMovies(currentMovie?.id.toString())
- val similarMovies by viewModel.similarMoviesLiveData.observeAsState()
- similarMovies?.let { movies ->
- Text(text = "Similar Movies", style = typography.h5, modifier = Modifier.padding(8.dp))
- LazyRow {
- items(
- items = movies,
- itemContent = { movie: Movie ->
- Image(
- painter =
- rememberImagePainter(data = "https://image.tmdb.org/t/p/w500/${movie.poster_path}"),
- contentDescription = null,
- modifier =
- Modifier.width(200.dp).height(300.dp).padding(12.dp).clip(RoundedCornerShape(12.dp)),
- contentScale = ContentScale.Crop,
- )
- },
- )
- }
- }
-}
+package com.guru.composecookbook.moviesapp.ui.details.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil3.compose.rememberAsyncImagePainter
+import com.guru.composecookbook.moviesapp.data.db.models.Movie
+import com.guru.composecookbook.moviesapp.ui.details.MovieDetailViewModel
+import com.guru.composecookbook.theme.typography
+
+@Composable
+fun SimilarMoviesSection(currentMovie: Movie?, viewModel: MovieDetailViewModel) {
+ viewModel.getSimilarMovies(currentMovie?.id.toString())
+ val similarMovies by viewModel.similarMoviesLiveData.observeAsState()
+ similarMovies?.let { movies ->
+ Text(text = "Similar Movies", style = typography.h5, modifier = Modifier.padding(8.dp))
+ LazyRow {
+ items(
+ items = movies,
+ itemContent = { movie: Movie ->
+ Image(
+ painter =
+ rememberAsyncImagePainter(
+ model = "https://image.tmdb.org/t/p/w500/${movie.poster_path}"
+ ),
+ contentDescription = null,
+ modifier =
+ Modifier.width(200.dp).height(300.dp).padding(12.dp).clip(RoundedCornerShape(12.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ },
+ )
+ }
+ }
+}
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/MoviesHomeActivity.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/MoviesHomeActivity.kt
index 535da41b..93efedf0 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/MoviesHomeActivity.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/MoviesHomeActivity.kt
@@ -26,7 +26,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
-import coil.annotation.ExperimentalCoilApi
+import coil3.annotation.ExperimentalCoilApi
import com.guru.composecookbook.moviesapp.data.db.models.Movie
import com.guru.composecookbook.moviesapp.ui.details.MovieDetailActivity
import com.guru.composecookbook.moviesapp.ui.home.components.MovieHomeScreen
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/components/MoviePagerItem.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/components/MoviePagerItem.kt
index 334e5281..cbe3f0ec 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/components/MoviePagerItem.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/home/components/MoviePagerItem.kt
@@ -33,7 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.moviesapp.data.DemoMovieDataProvider
import com.guru.composecookbook.moviesapp.data.db.models.Genre
import com.guru.composecookbook.moviesapp.data.db.models.Movie
@@ -94,7 +94,7 @@ fun MoviePagerItem(
) {
Column {
Image(
- painter = rememberImagePainter(data = posterFullPath),
+ painter = rememberAsyncImagePainter(model = posterFullPath),
contentScale = ContentScale.Crop,
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(360.dp),
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/trending/components/MoviesLaneItem.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/trending/components/MoviesLaneItem.kt
index e23ea3dd..864b77a9 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/trending/components/MoviesLaneItem.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/trending/components/MoviesLaneItem.kt
@@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
-import coil.compose.rememberImagePainter
+import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.moviesapp.data.db.models.Movie
import com.guru.composecookbook.theme.typography
@@ -33,7 +33,9 @@ fun MoviesLaneItem(movies: List, title: String = "", onMovieSelected: (Mo
itemContent = { movie: Movie ->
Image(
painter =
- rememberImagePainter(data = "https://image.tmdb.org/t/p/w500/${movie.poster_path}"),
+ rememberAsyncImagePainter(
+ model = "https://image.tmdb.org/t/p/w500/${movie.poster_path}"
+ ),
contentDescription = null,
modifier =
Modifier.width(190.dp)
diff --git a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/watch/components/MovieWatchlistItem.kt b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/watch/components/MovieWatchlistItem.kt
index f7ee868b..a9086710 100644
--- a/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/watch/components/MovieWatchlistItem.kt
+++ b/demos/moviesapp/app/src/main/java/com/guru/composecookbook/moviesapp/ui/watch/components/MovieWatchlistItem.kt
@@ -1,56 +1,58 @@
-package com.guru.composecookbook.moviesapp.ui.watch.components
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.RemoveCircleOutline
-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.layout.ContentScale
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import coil.compose.rememberImagePainter
-import com.guru.composecookbook.moviesapp.data.db.models.Movie
-import com.guru.composecookbook.theme.typography
-
-@Composable
-fun MovieWatchlistItem(
- movie: Movie,
- onMovieSelected: () -> Unit,
- onRemoveFromWatchlist: () -> Unit,
-) {
- Box(modifier = Modifier.clickable(onClick = onMovieSelected)) {
- Image(
- painter =
- rememberImagePainter(data = "https://image.tmdb.org/t/p/original/${movie.backdrop_path}"),
- contentDescription = null,
- modifier = Modifier.fillMaxWidth().height(280.dp),
- contentScale = ContentScale.Crop,
- )
- Spacer( // overlay
- modifier = Modifier.fillMaxWidth().height(280.dp).background(Color.Black.copy(alpha = 0.1f))
- )
- Text(
- text = movie.title,
- style = typography.h6.copy(fontWeight = FontWeight.ExtraBold),
- modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
- )
- IconButton(
- onClick = { onRemoveFromWatchlist.invoke() },
- modifier = Modifier.align(Alignment.BottomEnd),
- ) {
- Icon(imageVector = Icons.Default.RemoveCircleOutline, contentDescription = null)
- }
- }
-}
+package com.guru.composecookbook.moviesapp.ui.watch.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.RemoveCircleOutline
+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.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import coil3.compose.rememberAsyncImagePainter
+import com.guru.composecookbook.moviesapp.data.db.models.Movie
+import com.guru.composecookbook.theme.typography
+
+@Composable
+fun MovieWatchlistItem(
+ movie: Movie,
+ onMovieSelected: () -> Unit,
+ onRemoveFromWatchlist: () -> Unit,
+) {
+ Box(modifier = Modifier.clickable(onClick = onMovieSelected)) {
+ Image(
+ painter =
+ rememberAsyncImagePainter(
+ model = "https://image.tmdb.org/t/p/original/${movie.backdrop_path}"
+ ),
+ contentDescription = null,
+ modifier = Modifier.fillMaxWidth().height(280.dp),
+ contentScale = ContentScale.Crop,
+ )
+ Spacer( // overlay
+ modifier = Modifier.fillMaxWidth().height(280.dp).background(Color.Black.copy(alpha = 0.1f))
+ )
+ Text(
+ text = movie.title,
+ style = typography.h6.copy(fontWeight = FontWeight.ExtraBold),
+ modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
+ )
+ IconButton(
+ onClick = { onRemoveFromWatchlist.invoke() },
+ modifier = Modifier.align(Alignment.BottomEnd),
+ ) {
+ Icon(imageVector = Icons.Default.RemoveCircleOutline, contentDescription = null)
+ }
+ }
+}
diff --git a/demos/tiktok/build.gradle.kts b/demos/tiktok/build.gradle.kts
index 05f45d1c..f8ae2a0b 100644
--- a/demos/tiktok/build.gradle.kts
+++ b/demos/tiktok/build.gradle.kts
@@ -1,11 +1,13 @@
import com.guru.composecookbook.build.dependencies.addComposeOfficialDependencies
import com.guru.composecookbook.build.dependencies.addCoreAndroidUiDependencies
import com.guru.composecookbook.build.dependencies.addGoogleAndroidDependencies
+import com.guru.composecookbook.build.dependencies.addKotlinDependencies
import com.guru.composecookbook.build.dependencies.addThirdPartyUiDependencies
plugins {
/** See [common-compose-module-configs-script-plugin.gradle.kts] file */
id("common-compose-module-configs-script-plugin")
+ id("org.jetbrains.kotlin.plugin.serialization")
}
android { namespace = "com.guru.composecookbook.tiktok" }
@@ -16,6 +18,7 @@ dependencies {
implementation(project(":data"))
implementation(project(":theme"))
+ addKotlinDependencies()
addComposeOfficialDependencies()
addCoreAndroidUiDependencies()
addThirdPartyUiDependencies()
diff --git a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TikTokScreen.kt b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TikTokScreen.kt
index ae9df718..86af936d 100644
--- a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TikTokScreen.kt
+++ b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TikTokScreen.kt
@@ -1,15 +1,41 @@
package com.guru.composecookbook.tiktok
-sealed class TikTokScreen(val route: String) {
- object Home : TikTokScreen("Home")
+import kotlinx.serialization.Serializable
- object Discover : TikTokScreen("Discover")
+/**
+ * Type-safe navigation routes for the TikTok demo. Each screen is a serializable route; [route] is
+ * kept as the human readable label shown in the bottom bar.
+ */
+sealed interface TikTokScreen {
+ val route: String
- object Create : TikTokScreen("Create")
+ @Serializable
+ data object Home : TikTokScreen {
+ override val route = "Home"
+ }
- object Inbox : TikTokScreen("Inbox")
+ @Serializable
+ data object Discover : TikTokScreen {
+ override val route = "Discover"
+ }
- object Me : TikTokScreen("Me")
+ @Serializable
+ data object Create : TikTokScreen {
+ override val route = "Create"
+ }
- object Profile : TikTokScreen("Profile")
+ @Serializable
+ data object Inbox : TikTokScreen {
+ override val route = "Inbox"
+ }
+
+ @Serializable
+ data object Me : TikTokScreen {
+ override val route = "Me"
+ }
+
+ @Serializable
+ data class Profile(val userId: String) : TikTokScreen {
+ override val route = "Profile"
+ }
}
diff --git a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TiktokActivity.kt b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TiktokActivity.kt
index 57438e1d..58319dae 100644
--- a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TiktokActivity.kt
+++ b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/TiktokActivity.kt
@@ -20,11 +20,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
+import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
import com.guru.composecookbook.theme.ComposeCookBookTheme
import com.guru.composecookbook.theme.tiktokBlack
import com.guru.composecookbook.tiktok.components.discovers.DiscoverScreen
@@ -60,17 +62,18 @@ fun TiktokAppContent() {
@Composable
fun TikTokBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
- val currentRoute = navBackStackEntry?.arguments?.getString("route")
+ val currentDestination = navBackStackEntry?.destination
BottomNavigation(backgroundColor = tiktokBlack) {
TiktokDemoDataProvider.bottomBarList.forEach { tiktokScreen ->
+ val isSelected = currentDestination?.hasRoute(tiktokScreen::class) == true
BottomNavigationItem(
icon = { BottomBarIcon(tiktokScreen) },
- selected = currentRoute == tiktokScreen.route,
+ selected = isSelected,
onClick = {
navController.popBackStack(navController.graph.startDestinationId, false)
- if (currentRoute != tiktokScreen.route) {
- navController.navigate(tiktokScreen.route)
+ if (!isSelected) {
+ navController.navigate(tiktokScreen)
}
},
label = {
@@ -91,7 +94,7 @@ fun BottomBarIcon(screen: TikTokScreen) {
TikTokScreen.Create -> TiktokCreateIcon()
TikTokScreen.Inbox -> Icon(imageVector = Icons.Filled.Inbox, contentDescription = null)
TikTokScreen.Me -> Icon(imageVector = Icons.Filled.Person, contentDescription = null)
- TikTokScreen.Profile -> {
+ is TikTokScreen.Profile -> {
/* this branch added to satisfy exhaustive when */
}
}
@@ -101,19 +104,19 @@ fun BottomBarIcon(screen: TikTokScreen) {
fun TikTokBodyContent(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
- startDestination = TikTokScreen.Home.route,
+ startDestination = TikTokScreen.Home,
modifier = modifier,
) {
- composable(TikTokScreen.Home.route) {
+ composable {
HomeScreen(tiktokInteractionEvents = { handleInteractionEvent(it, navController) })
}
- composable(TikTokScreen.Discover.route) { DiscoverScreen() }
- composable(TikTokScreen.Create.route) { Text(text = "Create:TODO") }
- composable(TikTokScreen.Inbox.route) { Text(text = "Inbox:TODO") }
- composable(TikTokScreen.Me.route) { ProfileScreen("10", navController) }
+ composable { DiscoverScreen() }
+ composable { Text(text = "Create:TODO") }
+ composable { Text(text = "Inbox:TODO") }
+ composable { ProfileScreen("10", navController) }
// This navigation is for going to user profile but it should be moved to separate place
- composable("${TikTokScreen.Profile.route}/{userId}") { backStackEntry ->
- ProfileScreen(backStackEntry.arguments?.getString("userId")!!, navController)
+ composable { backStackEntry ->
+ ProfileScreen(backStackEntry.toRoute().userId, navController)
}
}
}
@@ -125,7 +128,7 @@ fun handleInteractionEvent(
when (tiktokHomeInteractionEvents) {
is TiktokHomeInteractionEvents.OpenProfile -> {
navController.navigate(
- "${TikTokScreen.Profile.route}/${tiktokHomeInteractionEvents.album.id}"
+ TikTokScreen.Profile(userId = tiktokHomeInteractionEvents.album.id.toString())
)
}
else -> {
diff --git a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/discovers/MediaItem.kt b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/discovers/MediaItem.kt
index a9fa1275..ac401e9e 100644
--- a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/discovers/MediaItem.kt
+++ b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/discovers/MediaItem.kt
@@ -1,21 +1,21 @@
-package com.guru.composecookbook.tiktok.components.discovers
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.unit.dp
-import coil.compose.rememberImagePainter
-
-@Composable
-fun MediaItem(id: Int, modifier: Modifier = Modifier) {
- Image(
- painter = rememberImagePainter(data = "https://picsum.photos/id/${id}/200/200"),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = modifier.height(150.dp).width(120.dp).padding(2.dp),
- )
-}
+package com.guru.composecookbook.tiktok.components.discovers
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil3.compose.rememberAsyncImagePainter
+
+@Composable
+fun MediaItem(id: Int, modifier: Modifier = Modifier) {
+ Image(
+ painter = rememberAsyncImagePainter(model = "https://picsum.photos/id/${id}/200/200"),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier.height(150.dp).width(120.dp).padding(2.dp),
+ )
+}
diff --git a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/home/TikTokPlayer.kt b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/home/TikTokPlayer.kt
index 0a1fd66f..82ae0a8c 100644
--- a/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/home/TikTokPlayer.kt
+++ b/demos/tiktok/src/main/java/com/guru/composecookbook/tiktok/components/home/TikTokPlayer.kt
@@ -1,30 +1,26 @@
package com.guru.composecookbook.tiktok.components.home
import android.content.Context
-import android.net.Uri
+import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.viewinterop.AndroidView
-import com.google.android.exoplayer2.C
-import com.google.android.exoplayer2.MediaItem
-import com.google.android.exoplayer2.Player
-import com.google.android.exoplayer2.SimpleExoPlayer
-import com.google.android.exoplayer2.source.ProgressiveMediaSource
-import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
-import com.google.android.exoplayer2.ui.PlayerView
-import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+@OptIn(UnstableApi::class)
@Composable
fun TikTokPlayer(context: Context, url: String, selected: Boolean) {
val tiktokPlayer = remember {
- SimpleExoPlayer.Builder(context).build().apply {
- val mediaSource =
- ProgressiveMediaSource.Factory(DefaultDataSourceFactory(context, "composeCookBook"))
- .createMediaSource(MediaItem.fromUri(Uri.parse("asset:///${url}")))
-
- this.setMediaSource(mediaSource, true)
- this.prepare()
+ ExoPlayer.Builder(context).build().apply {
+ setMediaItem(MediaItem.fromUri("asset:///$url"))
+ prepare()
}
}
tiktokPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
diff --git a/templates/login/build.gradle.kts b/templates/login/build.gradle.kts
index f27aa2d8..fc80b293 100644
--- a/templates/login/build.gradle.kts
+++ b/templates/login/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.guru.composecookbook.build.dependencies.addAndroidInstrumentationTestsDependencies
import com.guru.composecookbook.build.dependencies.addComposeOfficialDependencies
import com.guru.composecookbook.build.dependencies.addComposeThirdPartyDependencies
@@ -17,4 +18,6 @@ dependencies {
addComposeOfficialDependencies()
addComposeThirdPartyDependencies()
+
+ addAndroidInstrumentationTestsDependencies()
}
diff --git a/templates/login/src/androidTest/java/com/guru/composecookbook/login/LoginScreenTest.kt b/templates/login/src/androidTest/java/com/guru/composecookbook/login/LoginScreenTest.kt
new file mode 100644
index 00000000..db15c9de
--- /dev/null
+++ b/templates/login/src/androidTest/java/com/guru/composecookbook/login/LoginScreenTest.kt
@@ -0,0 +1,42 @@
+package com.guru.composecookbook.login
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextInput
+import org.junit.Rule
+import org.junit.Test
+
+class LoginScreenTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ private fun setLoginContent(onLoginSuccess: () -> Unit = {}) {
+ composeTestRule.setContent { LoginScreen(onLoginSuccess = onLoginSuccess) }
+ }
+
+ @Test
+ fun loginFieldsAreDisplayed() {
+ setLoginContent()
+
+ composeTestRule.onNodeWithText("Email address").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Password").assertIsDisplayed()
+ }
+
+ @Test
+ fun loginButtonIsDisplayed() {
+ setLoginContent()
+
+ composeTestRule.onNodeWithText("Log In").performScrollTo().assertIsDisplayed()
+ }
+
+ @Test
+ fun typedEmailIsReflectedInTheField() {
+ setLoginContent()
+
+ composeTestRule.onNodeWithText("Email address").performTextInput("user@example.com")
+
+ composeTestRule.onNodeWithText("user@example.com").assertIsDisplayed()
+ }
+}