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() + } +}