feat(android): сортировать весь список и добавить обратный порядок
Выбор порядка раньше действовал только на серверные альбомы: карточки устройства были закреплены сверху и не сортировались, а направления не было вовсе. - порядок применяется ко всему списку: карточки устройства и серверные упорядочиваются вместе единым компаратором - альбом устройства получает дату последнего изменения (самый свежий элемент, как при выборе обложки), поэтому «по времени изменения» осмыслен и для него - обратный порядок инвертирует выбранный ключ; карточка без даты остаётся последней в любом направлении - пара (ключ, направление) хранится в DataStore за узким портом AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок» - README: раздел Album sorting обновлён
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user