diff --git a/android/README.md b/android/README.md index 8cfbb50..0962072 100644 --- a/android/README.md +++ b/android/README.md @@ -188,6 +188,17 @@ server album shows `источник: `. back from the server (space permitting); declining keeps previews only. Unlinking an album drops the local link and never deletes photos on the server. +## Album sorting + +The album list can order every card by most recent change (the default), by title, +or by the number of items, and the chosen key can be reversed from the same menu. +Cards of device albums and server albums are ordered together, so one ordering rule +covers the whole list; a device album's change date is the newest item it holds, +which makes the default key meaningful for it as well. The choice — key and +direction — is made from the sort control in the top bar and is remembered on the +device in DataStore, alongside the other display settings; it is not synced, so each +device keeps its own ordering. + ## Testing ```sh diff --git a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt index 0e1261e..1188332 100644 --- a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt +++ b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt @@ -71,16 +71,20 @@ object DeviceMediaMapping { media.groupBy { it.albumId } .map { (albumId, items) -> val name = items.firstOrNull { it.albumName.isNotBlank() }?.albumName ?: "" - val cover = items.maxByOrNull { it.dateTaken ?: (it.dateModified * 1000) } + val latest = items.maxByOrNull { it.timestamp() } DeviceAlbum( id = albumId, name = name.ifBlank { "Без названия" }, itemCount = items.size, - coverUri = cover?.uri, + coverUri = latest?.uri, relativePath = items.first().relativePath, + updatedAtMillis = latest?.timestamp(), ) } .sortedBy { it.name.lowercase() } + + /** When an item was last changed, in epoch millis. */ + private fun DeviceMedia.timestamp(): Long = dateTaken ?: (dateModified * 1_000) } /** diff --git a/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt b/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt index b0c27f3..c2964f9 100644 --- a/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/prefs/SettingsRepository.kt @@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -21,7 +23,17 @@ data class SettingsState( val wifiOnlyUploads: Boolean, ) -class SettingsRepository(context: Context) { +/** + * The album-list ordering preference: the chosen key and direction, and where a + * new choice is stored. Narrow on purpose, so callers that only reorder albums + * depend on this rather than on the whole connection settings store. + */ +interface AlbumSortPreference { + val choice: Flow + suspend fun setChoice(choice: AlbumSortChoice) +} + +class SettingsRepository(context: Context) : AlbumSortPreference { private val dataStore = context.applicationContext.settingsDataStore @@ -33,6 +45,13 @@ class SettingsRepository(context: Context) { ) } + override val choice: Flow = dataStore.data.map { prefs -> + AlbumSortChoice( + sort = AlbumSort.from(prefs[KEY_ALBUM_SORT]), + reversed = prefs[KEY_ALBUM_SORT_REVERSED] ?: false, + ) + } + suspend fun setBaseUrl(url: String) { dataStore.edit { it[KEY_BASE_URL] = url.trim() } } @@ -45,6 +64,14 @@ class SettingsRepository(context: Context) { dataStore.edit { it[KEY_WIFI_ONLY] = enabled } } + /** Stores the album-list ordering; it is a per-device display preference. */ + override suspend fun setChoice(choice: AlbumSortChoice) { + dataStore.edit { prefs -> + prefs[KEY_ALBUM_SORT] = choice.sort.name + prefs[KEY_ALBUM_SORT_REVERSED] = choice.reversed + } + } + suspend fun clear() { dataStore.edit { prefs -> prefs.remove(KEY_BASE_URL) @@ -56,5 +83,7 @@ class SettingsRepository(context: Context) { val KEY_BASE_URL = stringPreferencesKey("base_url") val KEY_INSECURE = booleanPreferencesKey("insecure_allowed") val KEY_WIFI_ONLY = booleanPreferencesKey("wifi_only_uploads") + val KEY_ALBUM_SORT = stringPreferencesKey("album_sort") + val KEY_ALBUM_SORT_REVERSED = booleanPreferencesKey("album_sort_reversed") } } diff --git a/android/app/src/main/java/com/galo/android/domain/model/AlbumSort.kt b/android/app/src/main/java/com/galo/android/domain/model/AlbumSort.kt new file mode 100644 index 0000000..38700c7 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/AlbumSort.kt @@ -0,0 +1,39 @@ +package com.galo.android.domain.model + +import java.time.Instant + +/** + * How the album list is ordered. The choice is a display preference: it is stored + * locally on the device and never sent to the server. + */ +enum class AlbumSort { + /** Most recently changed first. */ + UPDATED, + + /** Alphabetically by title. */ + TITLE, + + /** Most items first. */ + PHOTOS, + ; + + companion object { + /** Parses a stored value, falling back to [UPDATED] for anything unknown. */ + fun from(raw: String?): AlbumSort = + entries.firstOrNull { it.name == raw } ?: UPDATED + } +} + +/** The chosen ordering: the key plus whether it is reversed. */ +data class AlbumSortChoice( + val sort: AlbumSort = AlbumSort.UPDATED, + val reversed: Boolean = false, +) + +/** + * The album's last change as epoch milliseconds, or null when the server reported + * no timestamp (or one that cannot be parsed); such albums sort last. + */ +fun Album.updatedAtMillis(): Long? = updatedAt?.let { + runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() +} diff --git a/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt index 59c6ec2..d3789fe 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt @@ -7,6 +7,8 @@ data class DeviceAlbum( val itemCount: Int, val coverUri: String?, val relativePath: String, + /** Newest item of the album, epoch millis; null when the bucket is empty. */ + val updatedAtMillis: Long? = null, ) /** App-only metadata for a device album: display name override and chosen cover. */ diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt index 197b973..2808ac1 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt @@ -1,8 +1,11 @@ package com.galo.android.ui.albums import com.galo.android.domain.model.Album +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.updatedAtMillis /** One row of the mixed album list: a server album or an album of the device. */ sealed interface AlbumListItem { @@ -36,6 +39,9 @@ data class AlbumLink( * Builds the mixed list. A linked device album is shown as a single card carrying * the connection to its server album; that server album is hidden from the list, * so a device album and its copy do not appear twice. + * + * Cards of both sources share one ordering: the chosen key applies to the whole + * list, device albums included. */ object AlbumListBuilder { @@ -43,6 +49,7 @@ object AlbumListBuilder { albums: List, links: List, devices: List, + choice: AlbumSortChoice = AlbumSortChoice(), ): List { val linksByDevice = links.associateBy { it.deviceAlbumId } val linkedServerIds = links @@ -65,6 +72,60 @@ object AlbumListBuilder { .filter { it.id !in linkedServerIds } .map { AlbumListItem.ServerAlbum(it, linkedDeviceNames = emptyList()) } - return deviceItems + serverItems + val comparator = orderKeyComparator(choice) + return (deviceItems + serverItems) + .map { it to it.orderKey() } + .sortedWith(Comparator { a, b -> comparator.compare(a.second, b.second) }) + .map { it.first } + } + + /** The values a card can be ordered by, available for both sources. */ + private data class OrderKey( + val title: String, + val count: Int, + val changedAt: Long?, + ) + + private fun AlbumListItem.orderKey(): OrderKey = when (this) { + is AlbumListItem.ServerAlbum -> + OrderKey(album.title, album.photosCount, album.updatedAtMillis()) + + is AlbumListItem.DeviceAlbumItem -> + OrderKey(album.name, album.itemCount, album.updatedAtMillis) + } + + /** + * Ordering for [choice]. Ties fall back to the title; a card without a change + * date is unknown rather than oldest, so it stays last in either direction. + */ + private fun orderKeyComparator(choice: AlbumSortChoice): Comparator { + val byTitle = compareBy { it.title.lowercase() } + return when (choice.sort) { + AlbumSort.TITLE -> + if (choice.reversed) { + compareByDescending { it.title.lowercase() }.then(byTitle) + } else { + byTitle + } + + AlbumSort.PHOTOS -> + (if (choice.reversed) { + compareBy { it.count } + } else { + compareByDescending { it.count } + }).then(byTitle) + + AlbumSort.UPDATED -> Comparator { a, b -> + val left = a.changedAt + val right = b.changedAt + when { + left == null && right == null -> 0 + left == null -> 1 + right == null -> -1 + choice.reversed -> left.compareTo(right) + else -> right.compareTo(left) + } + }.then(byTitle) + } } } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index 7b08129..a05eee1 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -16,13 +16,18 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SdCard import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -32,6 +37,9 @@ 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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.galo.android.domain.model.Album +import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode import com.galo.android.ui.GaloViewModelFactory @@ -51,6 +60,11 @@ import com.galo.android.ui.common.UserAvatar const val TAG_ALBUMS_GRID = "albums_grid" const val TAG_ALBUMS_OFFLINE = "albums_offline" const val TAG_ALBUMS_AVATAR = "albums_avatar" +const val TAG_ALBUMS_SORT = "albums_sort" +const val TAG_ALBUMS_SORT_OPTION_PREFIX = "albums_sort_option_" +const val TAG_ALBUMS_SORT_MARKED_PREFIX = "albums_sort_marked_" +const val TAG_ALBUMS_SORT_REVERSED = "albums_sort_reversed" +const val TAG_ALBUMS_SORT_MARKED_REVERSED = "albums_sort_marked_reversed" const val TAG_ALBUM_CARD_PREFIX = "album_card_" const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_" const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_" @@ -79,6 +93,8 @@ fun AlbumsScreen( onOpenProfile = onOpenProfile, onRefresh = viewModel::refresh, onLogout = { viewModel.logout(onLoggedOut) }, + onSortChange = viewModel::setSort, + onReversedChange = viewModel::setReversed, ) } @@ -92,12 +108,20 @@ fun AlbumsContent( onOpenProfile: () -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, + onSortChange: (AlbumSort) -> Unit, + onReversedChange: (Boolean) -> Unit, ) { Scaffold( topBar = { TopAppBar( title = { Text("Альбомы") }, actions = { + AlbumSortMenu( + sort = state.sort, + reversed = state.reversed, + onSortChange = onSortChange, + onReversedChange = onReversedChange, + ) IconButton(onClick = onOpenProfile, modifier = Modifier.testTag(TAG_ALBUMS_AVATAR)) { UserAvatar(url = state.avatarUrl, contentDescription = "Профиль", size = 28.dp) } @@ -169,6 +193,71 @@ fun AlbumsContent( } } +/** Sort control for the whole album list: the key plus its direction. */ +@Composable +private fun AlbumSortMenu( + sort: AlbumSort, + reversed: Boolean, + onSortChange: (AlbumSort) -> Unit, + onReversedChange: (Boolean) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { open = true }, + modifier = Modifier.testTag(TAG_ALBUMS_SORT), + ) { + Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Сортировка") + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + AlbumSort.entries.forEach { option -> + DropdownMenuItem( + text = { Text(option.label()) }, + onClick = { + open = false + onSortChange(option) + }, + leadingIcon = { + if (option == sort) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .testTag(TAG_ALBUMS_SORT_MARKED_PREFIX + option.name), + ) + } + }, + modifier = Modifier.testTag(TAG_ALBUMS_SORT_OPTION_PREFIX + option.name), + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Обратный порядок") }, + onClick = { + open = false + onReversedChange(!reversed) + }, + leadingIcon = { + if (reversed) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.testTag(TAG_ALBUMS_SORT_MARKED_REVERSED), + ) + } + }, + modifier = Modifier.testTag(TAG_ALBUMS_SORT_REVERSED), + ) + } + } +} + +private fun AlbumSort.label(): String = when (this) { + AlbumSort.UPDATED -> "По времени изменения" + AlbumSort.TITLE -> "По названию" + AlbumSort.PHOTOS -> "По числу фото" +} + /** Where a device album's media live. */ private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index bf8f384..2ada26d 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -3,13 +3,17 @@ package com.galo.android.ui.albums import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.galo.android.di.AppContainer +import com.galo.android.data.prefs.AlbumSortPreference import com.galo.android.data.repo.LiveAlbumSource +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -17,6 +21,7 @@ import kotlinx.coroutines.launch class AlbumsViewModel( private val container: AppContainer, private val live: LiveAlbumSource = container.albumLiveSync, + private val sortPreference: AlbumSortPreference = container.settingsRepository, ) : ViewModel() { data class UiState( @@ -26,6 +31,8 @@ class AlbumsViewModel( val error: String? = null, val avatarUrl: String? = null, val deviceAccess: Boolean = false, + val sort: AlbumSort = AlbumSort.UPDATED, + val reversed: Boolean = false, ) private data class Status( @@ -42,6 +49,9 @@ class AlbumsViewModel( private val deviceAccess = MutableStateFlow(false) + /** Current list ordering; restored from preferences and changed from the UI. */ + private val sort = MutableStateFlow(AlbumSortChoice()) + val state: StateFlow = combine( container.albumRepository.observeAlbums(), container.syncRepository.observeLinks(), @@ -49,8 +59,9 @@ class AlbumsViewModel( Triple(devices, access, meta) }, status, - avatar, - ) { albums, links, device, s, a -> + combine(avatar, sort) { url, choice -> url to choice }, + ) { albums, links, device, s, header -> + val (avatarUrl, sortChoice) = header val withMeta = device.first.map { d -> val m = device.third[d.id] d.copy( @@ -70,6 +81,7 @@ class AlbumsViewModel( ) }, devices = withMeta, + choice = sortChoice, ) UiState( @@ -77,8 +89,10 @@ class AlbumsViewModel( loading = s.loading, offline = s.offline, error = s.error, - avatarUrl = a, + avatarUrl = avatarUrl, deviceAccess = device.second, + sort = sortChoice.sort, + reversed = sortChoice.reversed, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true)) @@ -87,6 +101,22 @@ class AlbumsViewModel( // The personal stream lives as long as the session: it is started once // here and cancelled with the ViewModel scope on logout. viewModelScope.launch { live.run() } + viewModelScope.launch { + // Restore the saved ordering once; later changes come from the UI. + sort.value = sortPreference.choice.first() + } + } + + /** Changes the ordering key, keeping the chosen direction. */ + fun setSort(value: AlbumSort) = updateSort { it.copy(sort = value) } + + /** Reverses the current ordering key. */ + fun setReversed(reversed: Boolean) = updateSort { it.copy(reversed = reversed) } + + private fun updateSort(transform: (AlbumSortChoice) -> AlbumSortChoice) { + val next = transform(sort.value) + sort.value = next + viewModelScope.launch { sortPreference.setChoice(next) } } fun refresh() { diff --git a/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt b/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt index 48e4b9e..9b16bdc 100644 --- a/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt +++ b/android/app/src/test/java/com/galo/android/data/media/DeviceMediaMappingTest.kt @@ -63,6 +63,9 @@ class DeviceMediaMappingTest { assertEquals(listOf(2, 1), albums.map { it.itemCount }) // Cover is the most recently modified item of the bucket. assertEquals("content://media/external/images/media/3", albums.first().coverUri) + // The album's change date is its most recent item, in epoch millis. + assertEquals(30_000L, albums.first().updatedAtMillis) + assertEquals(10_000L, albums.last().updatedAtMillis) } @Test diff --git a/android/app/src/test/java/com/galo/android/data/prefs/SettingsRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/prefs/SettingsRepositoryTest.kt new file mode 100644 index 0000000..7fb6d90 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/prefs/SettingsRepositoryTest.kt @@ -0,0 +1,36 @@ +package com.galo.android.data.prefs + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SettingsRepositoryTest { + + private fun repository() = + SettingsRepository(ApplicationProvider.getApplicationContext()) + + @Test + fun `sort choice defaults to updated and a saved pair is read back`() = runBlocking { + val repository = repository() + + // Nothing has been saved yet, so the default applies. + assertEquals(AlbumSortChoice(), repository.choice.first()) + + val chosen = AlbumSortChoice(AlbumSort.PHOTOS, reversed = true) + repository.setChoice(chosen) + assertEquals(chosen, repository.choice.first()) + + // A new instance observes the same persisted pair. + assertEquals(chosen, repository().choice.first()) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/model/AlbumSortTest.kt b/android/app/src/test/java/com/galo/android/domain/model/AlbumSortTest.kt new file mode 100644 index 0000000..f17303c --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/model/AlbumSortTest.kt @@ -0,0 +1,56 @@ +package com.galo.android.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumSortTest { + + private fun album(updatedAt: String?) = Album( + id = "a1", + title = "Album", + description = "", + ownerId = "u1", + coverPhotoId = null, + coverThumbUrl = null, + coverMediumUrl = null, + photosCount = 0, + membersCount = 1, + updatedAt = updatedAt, + ) + + @Test + fun `unknown or missing stored values fall back to updated`() { + assertEquals(AlbumSort.UPDATED, AlbumSort.from(null)) + assertEquals(AlbumSort.UPDATED, AlbumSort.from("nonsense")) + assertEquals(AlbumSort.TITLE, AlbumSort.from("TITLE")) + assertEquals(AlbumSort.PHOTOS, AlbumSort.from("PHOTOS")) + } + + @Test + fun `the choice defaults to updated without reversal`() { + assertEquals(AlbumSort.UPDATED, AlbumSortChoice().sort) + assertEquals(false, AlbumSortChoice().reversed) + } + + @Test + fun `updatedAtMillis parses server timestamps in order`() { + val early = album("2026-01-01T00:00:00Z") + val late = album("2026-03-01T00:00:00Z") + + val earlyMillis = early.updatedAtMillis() + val lateMillis = late.updatedAtMillis() + + assertNotNull(earlyMillis) + assertNotNull(lateMillis) + assertTrue(earlyMillis!! < lateMillis!!) + } + + @Test + fun `updatedAtMillis is null for missing or malformed timestamps`() { + assertNull(album(null).updatedAtMillis()) + assertNull(album("not a date").updatedAtMillis()) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt index 851b757..875300d 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt @@ -1,6 +1,8 @@ package com.galo.android.ui.albums import com.galo.android.domain.model.Album +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode import org.junit.Assert.assertEquals @@ -9,7 +11,12 @@ import org.junit.Test class AlbumListBuilderTest { - private fun album(id: String, title: String) = Album( + private fun album( + id: String, + title: String, + photos: Int = 0, + updatedAt: String? = null, + ) = Album( id = id, title = title, description = "", @@ -17,19 +24,27 @@ class AlbumListBuilderTest { coverPhotoId = null, coverThumbUrl = null, coverMediumUrl = null, - photosCount = 0, + photosCount = photos, membersCount = 1, - updatedAt = null, + updatedAt = updatedAt, ) - private fun device(id: String, name: String) = DeviceAlbum( + private fun device( + id: String, + name: String, + count: Int = 3, + updated: Long? = null, + ) = DeviceAlbum( id = id, name = name, - itemCount = 3, + itemCount = count, coverUri = null, relativePath = "DCIM/$name/", + updatedAtMillis = updated, ) + private fun keys(items: List) = items.map { it.key } + @Test fun `a linked server album is collapsed into the device card`() { val items = AlbumListBuilder.build( @@ -65,4 +80,110 @@ class AlbumListBuilderTest { assertEquals(null, deviceItem.linkedServerTitle) assertEquals(SyncMode.COPY, deviceItem.mode) } + + /** + * Device b1 "Alpha" changed between s1 and s2, so each key gives its own order: + * updated s2,b1,s1 / title b1,s2,s1 / photos b1,s1,s2. + */ + private val mixedAlbums = listOf( + album("s1", "Zulu", photos = 5, updatedAt = "2026-02-01T00:00:00Z"), + album("s2", "Mid", photos = 1, updatedAt = "2026-03-01T00:00:00Z"), + ) + + private val mixedDevices = listOf(device("b1", "Alpha", count = 7, updated = 1_770_000_000_000L)) + + @Test + fun `the whole list follows the chosen key across both sources`() { + val albums = mixedAlbums + val devices = mixedDevices + + assertEquals( + listOf("server:s2", "device:b1", "server:s1"), + keys(AlbumListBuilder.build(albums, emptyList(), devices, AlbumSortChoice(AlbumSort.UPDATED))), + ) + assertEquals( + listOf("device:b1", "server:s2", "server:s1"), + keys(AlbumListBuilder.build(albums, emptyList(), devices, AlbumSortChoice(AlbumSort.TITLE))), + ) + assertEquals( + listOf("device:b1", "server:s1", "server:s2"), + keys(AlbumListBuilder.build(albums, emptyList(), devices, AlbumSortChoice(AlbumSort.PHOTOS))), + ) + } + + @Test + fun `reverse inverts each key`() { + val albums = mixedAlbums + val devices = mixedDevices + + assertEquals( + listOf("server:s1", "device:b1", "server:s2"), + keys( + AlbumListBuilder.build( + albums, emptyList(), devices, + AlbumSortChoice(AlbumSort.UPDATED, reversed = true), + ), + ), + ) + assertEquals( + listOf("server:s1", "server:s2", "device:b1"), + keys( + AlbumListBuilder.build( + albums, emptyList(), devices, + AlbumSortChoice(AlbumSort.TITLE, reversed = true), + ), + ), + ) + assertEquals( + listOf("server:s2", "server:s1", "device:b1"), + keys( + AlbumListBuilder.build( + albums, emptyList(), devices, + AlbumSortChoice(AlbumSort.PHOTOS, reversed = true), + ), + ), + ) + } + + @Test + fun `a card without a change date stays last in both directions`() { + val albums = listOf(album("s1", "Dated", updatedAt = "2026-01-01T00:00:00Z")) + val devices = listOf(device("b1", "Undated", updated = null)) + + assertEquals( + listOf("server:s1", "device:b1"), + keys(AlbumListBuilder.build(albums, emptyList(), devices, AlbumSortChoice(AlbumSort.UPDATED))), + ) + assertEquals( + listOf("server:s1", "device:b1"), + keys( + AlbumListBuilder.build( + albums, emptyList(), devices, + AlbumSortChoice(AlbumSort.UPDATED, reversed = true), + ), + ), + ) + } + + @Test + fun `a linked pair is ordered as its device card`() { + val items = AlbumListBuilder.build( + albums = listOf( + album("s1", "Zulu", updatedAt = "2026-01-01T00:00:00Z"), + album("s2", "Alpha", updatedAt = "2026-02-01T00:00:00Z"), + ), + links = listOf(AlbumLink("b1", "Zulu", "s1", SyncMode.COPY)), + devices = listOf(device("b1", "Zulu", updated = 1_770_000_000_000L)), + choice = AlbumSortChoice(AlbumSort.TITLE), + ) + + // The pair stays one card, ordered by the device album's name, and the + // linked server album is not listed on its own. + assertEquals(listOf("server:s2", "device:b1"), keys(items)) + } + + @Test + fun `an empty list stays empty`() { + assertTrue(AlbumListBuilder.build(emptyList(), emptyList(), emptyList()).isEmpty()) + } } diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index d8cd99e..f2f8bc5 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -1,16 +1,24 @@ package com.galo.android.ui.albums 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.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import com.galo.android.domain.model.Album +import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -24,7 +32,12 @@ class AlbumsContentTest { @get:Rule val compose = createComposeRule() - private fun album(id: String, title: String) = Album( + private fun album( + id: String, + title: String, + photos: Int = 4, + updated: String? = null, + ) = Album( id = id, title = title, description = "", @@ -32,9 +45,9 @@ class AlbumsContentTest { coverPhotoId = null, coverThumbUrl = null, coverMediumUrl = null, - photosCount = 4, + photosCount = photos, membersCount = 2, - updatedAt = null, + updatedAt = updated, ) private fun device(id: String, name: String, count: Int = 3) = DeviceAlbum( @@ -56,6 +69,8 @@ class AlbumsContentTest { onOpenProfile = {}, onRefresh = {}, onLogout = {}, + onSortChange = {}, + onReversedChange = {}, ) } } @@ -147,4 +162,58 @@ class AlbumsContentTest { } compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed() } + + @Test + fun `choosing a sort reorders the list and the marks follow`() { + val albums = listOf( + album("a1", "Alpha", photos = 1, updated = "2026-01-01T00:00:00Z"), + album("a2", "Zulu", photos = 9, updated = "2026-02-01T00:00:00Z"), + ) + compose.setContent { + var choice by remember { mutableStateOf(AlbumSortChoice()) } + GaloTheme(dynamicColor = false) { + AlbumsContent( + state = AlbumsViewModel.UiState( + items = AlbumListBuilder.build(albums, emptyList(), emptyList(), choice), + sort = choice.sort, + reversed = choice.reversed, + ), + onOpenAlbum = {}, + onOpenDeviceAlbum = {}, + onOpenUploads = {}, + onOpenProfile = {}, + onRefresh = {}, + onLogout = {}, + onSortChange = { choice = choice.copy(sort = it) }, + onReversedChange = { choice = choice.copy(reversed = it) }, + ) + } + } + + // Most recently changed first: Zulu (a2) before Alpha (a1). + assertTrue(leftOf("a2") < leftOf("a1")) + compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick() + compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_PREFIX + "UPDATED", useUnmergedTree = true) + .assertExists() + + compose.onNodeWithTag(TAG_ALBUMS_SORT_OPTION_PREFIX + "TITLE").performClick() + compose.waitForIdle() + // Alphabetical order puts Alpha (a1) first. + assertTrue(leftOf("a1") < leftOf("a2")) + compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick() + compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_PREFIX + "TITLE", useUnmergedTree = true) + .assertExists() + + // Reversing the chosen key turns it into Z before A. + compose.onNodeWithTag(TAG_ALBUMS_SORT_REVERSED).performClick() + compose.waitForIdle() + assertTrue(leftOf("a2") < leftOf("a1")) + compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick() + compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_REVERSED, useUnmergedTree = true) + .assertExists() + } + + /** Horizontal position of a card; two cards share the first grid row. */ + private fun leftOf(id: String) = + compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + id).getUnclippedBoundsInRoot().left } diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt index 6e1e842..753fbcb 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt @@ -2,9 +2,19 @@ package com.galo.android.ui.albums import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.network.PersonalEvent +import com.galo.android.data.network.dto.AlbumDto +import com.galo.android.data.prefs.AlbumSortPreference import com.galo.android.data.repo.LiveAlbumSource import com.galo.android.di.AppContainer +import com.galo.android.domain.model.AlbumSort +import com.galo.android.domain.model.AlbumSortChoice import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -41,16 +51,81 @@ class AlbumsViewModelTest { } } + /** In-memory ordering preference, so these tests never touch DataStore. */ + private class FakeSort(initial: AlbumSortChoice = AlbumSortChoice()) : AlbumSortPreference { + private val state = MutableStateFlow(initial) + val saved = mutableListOf() + + override val choice: Flow = state + + override suspend fun setChoice(choice: AlbumSortChoice) { + saved += choice + state.value = choice + } + } + + private fun container() = AppContainer(ApplicationProvider.getApplicationContext()) + @Test fun `the personal stream starts once and is not restarted by reading state`() { - val container = AppContainer(ApplicationProvider.getApplicationContext()) + val container = container() val live = CountingLive() - val viewModel = AlbumsViewModel(container, live) + val viewModel = AlbumsViewModel(container, live, FakeSort()) viewModel.state.value viewModel.state.value viewModel.refresh() assertEquals(1, live.runs) } + + @Test + fun `changing the sort reorders the cached albums without refetching`() = runBlocking { + val container = container() + // Three albums whose three orderings all differ: updated s2,s1,s3 / + // title s2,s3,s1 / photos s3,s2,s1. + listOf( + AlbumDto(id = "s1", title = "Zulu", photosCount = 1, updatedAt = "2026-02-01T00:00:00Z"), + AlbumDto(id = "s2", title = "Alpha", photosCount = 5, updatedAt = "2026-03-01T00:00:00Z"), + AlbumDto(id = "s3", title = "Mike", photosCount = 9, updatedAt = "2026-01-01T00:00:00Z"), + ).forEach { container.albumRepository.apply(PersonalEvent.AlbumUpserted(it)) } + + val sort = FakeSort() + val viewModel = AlbumsViewModel(container, CountingLive(), sort) + val job = launch { viewModel.state.collect { } } + try { + assertEquals(listOf("s2", "s1", "s3"), awaitServerIds(viewModel, listOf("s2", "s1", "s3"))) + + viewModel.setSort(AlbumSort.TITLE) + assertEquals(listOf("s2", "s3", "s1"), awaitServerIds(viewModel, listOf("s2", "s3", "s1"))) + + viewModel.setReversed(true) + assertEquals(listOf("s1", "s3", "s2"), awaitServerIds(viewModel, listOf("s1", "s3", "s2"))) + + // Switching the key keeps the direction: fewest photos first. + viewModel.setSort(AlbumSort.PHOTOS) + assertEquals(listOf("s1", "s2", "s3"), awaitServerIds(viewModel, listOf("s1", "s2", "s3"))) + + // The pair is handed to the preference for the device to keep. + assertEquals(AlbumSortChoice(AlbumSort.PHOTOS, reversed = true), sort.saved.last()) + } finally { + job.cancel() + } + } + + /** Waits for the server block to settle on [expected], then returns what it saw. */ + private suspend fun awaitServerIds(viewModel: AlbumsViewModel, expected: List): List { + val deadline = System.currentTimeMillis() + 10_000 + var ids = serverIds(viewModel) + while (System.currentTimeMillis() < deadline && ids != expected) { + delay(50) + ids = serverIds(viewModel) + } + return ids + } + + private fun serverIds(viewModel: AlbumsViewModel): List = + viewModel.state.value.items + .filterIsInstance() + .map { it.album.id } }