feat(android): сортировать весь список и добавить обратный порядок

Выбор порядка раньше действовал только на серверные альбомы: карточки устройства
были закреплены сверху и не сортировались, а направления не было вовсе.

- порядок применяется ко всему списку: карточки устройства и серверные
  упорядочиваются вместе единым компаратором
- альбом устройства получает дату последнего изменения (самый свежий элемент,
  как при выборе обложки), поэтому «по времени изменения» осмыслен и для него
- обратный порядок инвертирует выбранный ключ; карточка без даты остаётся
  последней в любом направлении
- пара (ключ, направление) хранится в DataStore за узким портом
  AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок»
- README: раздел Album sorting обновлён
This commit is contained in:
iu
2026-10-16 13:27:20 +03:00
parent 28e3d4676f
commit 3e8a6b87b7
14 changed files with 642 additions and 17 deletions
+11
View File
@@ -188,6 +188,17 @@ server album shows `источник: <device albums>`.
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
@@ -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)
}
/**
@@ -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<AlbumSortChoice>
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<AlbumSortChoice> = 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")
}
}
@@ -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()
}
@@ -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. */
@@ -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<Album>,
links: List<AlbumLink>,
devices: List<DeviceAlbum>,
choice: AlbumSortChoice = AlbumSortChoice(),
): List<AlbumListItem> {
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<OrderKey> {
val byTitle = compareBy<OrderKey> { it.title.lowercase() }
return when (choice.sort) {
AlbumSort.TITLE ->
if (choice.reversed) {
compareByDescending<OrderKey> { it.title.lowercase() }.then(byTitle)
} else {
byTitle
}
AlbumSort.PHOTOS ->
(if (choice.reversed) {
compareBy<OrderKey> { it.count }
} else {
compareByDescending<OrderKey> { it.count }
}).then(byTitle)
AlbumSort.UPDATED -> Comparator<OrderKey> { 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)
}
}
}
@@ -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 }
@@ -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<UiState> = 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() {
@@ -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
@@ -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<Context>())
@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())
}
}
@@ -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())
}
}
@@ -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<AlbumListItem>) = 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())
}
}
@@ -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
}
@@ -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<AlbumSortChoice>()
override val choice: Flow<AlbumSortChoice> = state
override suspend fun setChoice(choice: AlbumSortChoice) {
saved += choice
state.value = choice
}
}
private fun container() = AppContainer(ApplicationProvider.getApplicationContext<Context>())
@Test
fun `the personal stream starts once and is not restarted by reading state`() {
val container = AppContainer(ApplicationProvider.getApplicationContext<Context>())
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<String>): List<String> {
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<String> =
viewModel.state.value.items
.filterIsInstance<AlbumListItem.ServerAlbum>()
.map { it.album.id }
}