diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index 03ba3255..8360e2c6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -2,11 +2,19 @@ package com.subtracks.ui.home import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -14,13 +22,18 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState @@ -35,10 +48,10 @@ import com.subtracks.data.model.Song import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget -import com.subtracks.ui.library.AlbumsContent import com.subtracks.ui.library.ArtistsContent import com.subtracks.ui.library.SongRow import org.koin.compose.koinInject @@ -112,6 +125,7 @@ fun HomeListScreen( onDecadeClick: (Long) -> Unit = {}, modifier: Modifier = Modifier, ) { + val now = remember { System.currentTimeMillis() } Scaffold( modifier = modifier, topBar = { @@ -182,10 +196,10 @@ fun HomeListScreen( modifier = Modifier.padding(padding), ) } else { - AlbumsContent( - items = albums, + AlbumsList( + albums = albums, coverArt = coverArt, - bottomInset = 0.dp, + info = { albumListInfo(request, it, now) }, onAlbumClick = onAlbumClick, onLongClick = onLongClick, modifier = Modifier.padding(padding), @@ -250,3 +264,140 @@ private fun SongsList( } } } + +private val ALBUM_ROW_COVER = 48.dp + +@Composable +private fun AlbumsList( + albums: LazyPagingItems, + coverArt: (String?, Boolean) -> CoverArtRef?, + info: (Album) -> String?, + onAlbumClick: (Album) -> Unit, + onLongClick: (MenuTarget) -> Unit, + modifier: Modifier = Modifier, +) { + if (albums.itemCount == 0 && albums.loadState.refresh is LoadState.Loading) { + LoadingState(modifier) + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items(count = albums.itemCount, key = albums.itemKey { it.id }) { index -> + val album = albums[index] + if (album != null) { + AlbumListRow( + album = album, + coverArt = coverArt, + info = info(album), + onClick = { onAlbumClick(album) }, + onLongClick = { onLongClick(MenuTarget.Album(album, coverArt(album.coverArt, true))) }, + ) + } + } + } +} + +@Composable +private fun AlbumListRow( + album: Album, + coverArt: (String?, Boolean) -> CoverArtRef?, + info: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoverArt( + ref = coverArt(album.coverArt, true), + name = album.name, + modifier = Modifier.size(ALBUM_ROW_COVER).clip(RoundedCornerShape(2.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = album.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = album.albumArtist.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (info != null) { + Text( + text = info, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + } +} + +internal fun albumListInfo( + request: HomeListRequest, + album: Album, + now: Long, +): String? = + when { + request.section == HomeSection.RecentlyAddedAlbums -> { + relativeTimeLabel(album.created, now) + } + + request.section == HomeSection.RecentlyPlayedAlbums || request.section == HomeSection.Rediscover -> { + album.played?.let { relativeTimeLabel(it, now) } + } + + request.section == HomeSection.MostPlayedAlbums -> { + playCountLabel(album.playCount) + } + + request.decade != null -> { + album.year?.takeIf { it > 0 }?.toString() + } + + else -> { + null + } + } + +private fun playCountLabel(count: Long): String? = if (count <= 0L) null else "$count ${if (count == 1L) "play" else "plays"}" + +internal fun relativeTimeLabel( + epochSeconds: Long, + nowMillis: Long, +): String? { + if (epochSeconds <= 0L) return null + val elapsedMillis = nowMillis - epochSeconds * 1000L + if (elapsedMillis < 0L) return "Just now" + val days = elapsedMillis / 86_400_000L + return when { + elapsedMillis < 60_000L -> "Just now" + days == 0L -> "Today" + days == 1L -> "Yesterday" + days < 7L -> "$days days ago" + days < 14L -> "Last week" + days < 30L -> "${days / 7L} weeks ago" + days < 60L -> "Last month" + days < 365L -> "${days / 30L} months ago" + days < 730L -> "Last year" + else -> "${days / 365L} years ago" + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt new file mode 100644 index 00000000..0c71866d --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt @@ -0,0 +1,76 @@ +package com.subtracks.ui.home + +import com.subtracks.data.model.Album +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HomeListInfoTest { + private val now = 1_700_000_000_000L + + @Test + fun relativeTimeUsesFriendlyBuckets() { + assertNull(relativeTimeLabel(0, now)) + assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now)) + assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now)) + assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now)) + assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now)) + assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now)) + assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now)) + assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now)) + assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now)) + assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now)) + assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now)) + } + + @Test + fun albumInfoMatchesTheSection() { + val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999) + + assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now)) + assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now)) + assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now)) + assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now)) + } + + @Test + fun albumInfoIsNullWhenTheSectionHasNothingToShow() { + val album = album(created = 0, played = null, playCount = 0, year = 0) + + assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now)) + assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now)) + assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now)) + } + + @Test + fun aDecadeListShowsTheYear() { + val album = album(created = 0, played = null, playCount = 0, year = 1999) + + assertEquals("1999", albumListInfo(HomeListRequest(title = "1990s", decade = 1990), album, now)) + } + + private fun secondsAgo(days: Long): Long = (now - days * 86_400_000L) / 1000L + + private fun request(section: HomeSection) = HomeListRequest(title = section.title, section = section) + + private fun album( + created: Long, + played: Long?, + playCount: Long, + year: Long, + ) = Album( + sourceId = 1, + id = "a", + artistId = "ar", + name = "Album", + albumArtist = "Artist", + created = created, + coverArt = null, + genre = null, + year = year, + starred = null, + songCount = 1, + playCount = playCount, + played = played, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt index 04a39a2c..1fb952cb 100644 --- a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt @@ -3,12 +3,15 @@ package com.subtracks.ui.home import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeUp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.captureRoboImage @@ -17,6 +20,7 @@ import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.Artist import com.subtracks.data.model.Song import com.subtracks.ui.theme.SubtracksTheme +import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -75,6 +79,49 @@ class HomeScreenshotTest { composeRule.onRoot().captureRoboImage("src/test/screenshots/home_genres.png") } + @Test + fun homeRecentlyAddedList() { + val now = System.currentTimeMillis() + val albums = + listOf( + listAlbum("Added Yesterday", (now - 86_400_000L) / 1000L), + listAlbum("Added This Week", (now - 3 * 86_400_000L) / 1000L), + listAlbum("Added Weeks Ago", (now - 21 * 86_400_000L) / 1000L), + ) + composeRule.setContent { + SubtracksTheme { + HomeListScreen( + request = HomeListRequest(title = "Recently added", section = HomeSection.RecentlyAddedAlbums), + albums = remember { flowOf(PagingData.from(albums)) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + coverArt = { _, _ -> null }, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("Added Yesterday").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onRoot().captureRoboImage("src/test/screenshots/home_album_list.png") + } + + private fun listAlbum( + name: String, + created: Long, + ) = Album( + sourceId = 1, + id = name, + artistId = "ar", + name = name, + albumArtist = "Radiohead", + created = created, + coverArt = null, + genre = null, + year = 2000, + starred = null, + songCount = 10, + ) + private fun feed() = HomeFeed( recentlyPlayedAlbums = albums("Recent"),