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
|
back from the server (space permitting); declining keeps previews only. Unlinking
|
||||||
an album drops the local link and never deletes photos on the server.
|
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
|
## Testing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -71,16 +71,20 @@ object DeviceMediaMapping {
|
|||||||
media.groupBy { it.albumId }
|
media.groupBy { it.albumId }
|
||||||
.map { (albumId, items) ->
|
.map { (albumId, items) ->
|
||||||
val name = items.firstOrNull { it.albumName.isNotBlank() }?.albumName ?: ""
|
val name = items.firstOrNull { it.albumName.isNotBlank() }?.albumName ?: ""
|
||||||
val cover = items.maxByOrNull { it.dateTaken ?: (it.dateModified * 1000) }
|
val latest = items.maxByOrNull { it.timestamp() }
|
||||||
DeviceAlbum(
|
DeviceAlbum(
|
||||||
id = albumId,
|
id = albumId,
|
||||||
name = name.ifBlank { "Без названия" },
|
name = name.ifBlank { "Без названия" },
|
||||||
itemCount = items.size,
|
itemCount = items.size,
|
||||||
coverUri = cover?.uri,
|
coverUri = latest?.uri,
|
||||||
relativePath = items.first().relativePath,
|
relativePath = items.first().relativePath,
|
||||||
|
updatedAtMillis = latest?.timestamp(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase() }
|
.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.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
@@ -21,7 +23,17 @@ data class SettingsState(
|
|||||||
val wifiOnlyUploads: Boolean,
|
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
|
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) {
|
suspend fun setBaseUrl(url: String) {
|
||||||
dataStore.edit { it[KEY_BASE_URL] = url.trim() }
|
dataStore.edit { it[KEY_BASE_URL] = url.trim() }
|
||||||
}
|
}
|
||||||
@@ -45,6 +64,14 @@ class SettingsRepository(context: Context) {
|
|||||||
dataStore.edit { it[KEY_WIFI_ONLY] = enabled }
|
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() {
|
suspend fun clear() {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs.remove(KEY_BASE_URL)
|
prefs.remove(KEY_BASE_URL)
|
||||||
@@ -56,5 +83,7 @@ class SettingsRepository(context: Context) {
|
|||||||
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
val KEY_INSECURE = booleanPreferencesKey("insecure_allowed")
|
val KEY_INSECURE = booleanPreferencesKey("insecure_allowed")
|
||||||
val KEY_WIFI_ONLY = booleanPreferencesKey("wifi_only_uploads")
|
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 itemCount: Int,
|
||||||
val coverUri: String?,
|
val coverUri: String?,
|
||||||
val relativePath: 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. */
|
/** App-only metadata for a device album: display name override and chosen cover. */
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.galo.android.ui.albums
|
package com.galo.android.ui.albums
|
||||||
|
|
||||||
import com.galo.android.domain.model.Album
|
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.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
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. */
|
/** One row of the mixed album list: a server album or an album of the device. */
|
||||||
sealed interface AlbumListItem {
|
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
|
* 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,
|
* 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.
|
* 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 {
|
object AlbumListBuilder {
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ object AlbumListBuilder {
|
|||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
links: List<AlbumLink>,
|
links: List<AlbumLink>,
|
||||||
devices: List<DeviceAlbum>,
|
devices: List<DeviceAlbum>,
|
||||||
|
choice: AlbumSortChoice = AlbumSortChoice(),
|
||||||
): List<AlbumListItem> {
|
): List<AlbumListItem> {
|
||||||
val linksByDevice = links.associateBy { it.deviceAlbumId }
|
val linksByDevice = links.associateBy { it.deviceAlbumId }
|
||||||
val linkedServerIds = links
|
val linkedServerIds = links
|
||||||
@@ -65,6 +72,60 @@ object AlbumListBuilder {
|
|||||||
.filter { it.id !in linkedServerIds }
|
.filter { it.id !in linkedServerIds }
|
||||||
.map { AlbumListItem.ServerAlbum(it, linkedDeviceNames = emptyList()) }
|
.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.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
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.Cloud
|
||||||
import androidx.compose.material.icons.filled.CloudUpload
|
import androidx.compose.material.icons.filled.CloudUpload
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.SdCard
|
import androidx.compose.material.icons.filled.SdCard
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -32,6 +37,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -41,6 +49,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.galo.android.domain.model.Album
|
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.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import com.galo.android.ui.GaloViewModelFactory
|
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_GRID = "albums_grid"
|
||||||
const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
||||||
const val TAG_ALBUMS_AVATAR = "albums_avatar"
|
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_ALBUM_CARD_PREFIX = "album_card_"
|
||||||
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
|
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
|
||||||
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
|
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
|
||||||
@@ -79,6 +93,8 @@ fun AlbumsScreen(
|
|||||||
onOpenProfile = onOpenProfile,
|
onOpenProfile = onOpenProfile,
|
||||||
onRefresh = viewModel::refresh,
|
onRefresh = viewModel::refresh,
|
||||||
onLogout = { viewModel.logout(onLoggedOut) },
|
onLogout = { viewModel.logout(onLoggedOut) },
|
||||||
|
onSortChange = viewModel::setSort,
|
||||||
|
onReversedChange = viewModel::setReversed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +108,20 @@ fun AlbumsContent(
|
|||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onSortChange: (AlbumSort) -> Unit,
|
||||||
|
onReversedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Альбомы") },
|
title = { Text("Альбомы") },
|
||||||
actions = {
|
actions = {
|
||||||
|
AlbumSortMenu(
|
||||||
|
sort = state.sort,
|
||||||
|
reversed = state.reversed,
|
||||||
|
onSortChange = onSortChange,
|
||||||
|
onReversedChange = onReversedChange,
|
||||||
|
)
|
||||||
IconButton(onClick = onOpenProfile, modifier = Modifier.testTag(TAG_ALBUMS_AVATAR)) {
|
IconButton(onClick = onOpenProfile, modifier = Modifier.testTag(TAG_ALBUMS_AVATAR)) {
|
||||||
UserAvatar(url = state.avatarUrl, contentDescription = "Профиль", size = 28.dp)
|
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. */
|
/** Where a device album's media live. */
|
||||||
private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD }
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.galo.android.di.AppContainer
|
import com.galo.android.di.AppContainer
|
||||||
|
import com.galo.android.data.prefs.AlbumSortPreference
|
||||||
import com.galo.android.data.repo.LiveAlbumSource
|
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.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -17,6 +21,7 @@ import kotlinx.coroutines.launch
|
|||||||
class AlbumsViewModel(
|
class AlbumsViewModel(
|
||||||
private val container: AppContainer,
|
private val container: AppContainer,
|
||||||
private val live: LiveAlbumSource = container.albumLiveSync,
|
private val live: LiveAlbumSource = container.albumLiveSync,
|
||||||
|
private val sortPreference: AlbumSortPreference = container.settingsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
data class UiState(
|
data class UiState(
|
||||||
@@ -26,6 +31,8 @@ class AlbumsViewModel(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val avatarUrl: String? = null,
|
val avatarUrl: String? = null,
|
||||||
val deviceAccess: Boolean = false,
|
val deviceAccess: Boolean = false,
|
||||||
|
val sort: AlbumSort = AlbumSort.UPDATED,
|
||||||
|
val reversed: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class Status(
|
private data class Status(
|
||||||
@@ -42,6 +49,9 @@ class AlbumsViewModel(
|
|||||||
|
|
||||||
private val deviceAccess = MutableStateFlow(false)
|
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(
|
val state: StateFlow<UiState> = combine(
|
||||||
container.albumRepository.observeAlbums(),
|
container.albumRepository.observeAlbums(),
|
||||||
container.syncRepository.observeLinks(),
|
container.syncRepository.observeLinks(),
|
||||||
@@ -49,8 +59,9 @@ class AlbumsViewModel(
|
|||||||
Triple(devices, access, meta)
|
Triple(devices, access, meta)
|
||||||
},
|
},
|
||||||
status,
|
status,
|
||||||
avatar,
|
combine(avatar, sort) { url, choice -> url to choice },
|
||||||
) { albums, links, device, s, a ->
|
) { albums, links, device, s, header ->
|
||||||
|
val (avatarUrl, sortChoice) = header
|
||||||
val withMeta = device.first.map { d ->
|
val withMeta = device.first.map { d ->
|
||||||
val m = device.third[d.id]
|
val m = device.third[d.id]
|
||||||
d.copy(
|
d.copy(
|
||||||
@@ -70,6 +81,7 @@ class AlbumsViewModel(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
devices = withMeta,
|
devices = withMeta,
|
||||||
|
choice = sortChoice,
|
||||||
)
|
)
|
||||||
|
|
||||||
UiState(
|
UiState(
|
||||||
@@ -77,8 +89,10 @@ class AlbumsViewModel(
|
|||||||
loading = s.loading,
|
loading = s.loading,
|
||||||
offline = s.offline,
|
offline = s.offline,
|
||||||
error = s.error,
|
error = s.error,
|
||||||
avatarUrl = a,
|
avatarUrl = avatarUrl,
|
||||||
deviceAccess = device.second,
|
deviceAccess = device.second,
|
||||||
|
sort = sortChoice.sort,
|
||||||
|
reversed = sortChoice.reversed,
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
|
}.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
|
// The personal stream lives as long as the session: it is started once
|
||||||
// here and cancelled with the ViewModel scope on logout.
|
// here and cancelled with the ViewModel scope on logout.
|
||||||
viewModelScope.launch { live.run() }
|
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() {
|
fun refresh() {
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class DeviceMediaMappingTest {
|
|||||||
assertEquals(listOf(2, 1), albums.map { it.itemCount })
|
assertEquals(listOf(2, 1), albums.map { it.itemCount })
|
||||||
// Cover is the most recently modified item of the bucket.
|
// Cover is the most recently modified item of the bucket.
|
||||||
assertEquals("content://media/external/images/media/3", albums.first().coverUri)
|
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
|
@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
|
package com.galo.android.ui.albums
|
||||||
|
|
||||||
import com.galo.android.domain.model.Album
|
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.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -9,7 +11,12 @@ import org.junit.Test
|
|||||||
|
|
||||||
class AlbumListBuilderTest {
|
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,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
description = "",
|
description = "",
|
||||||
@@ -17,19 +24,27 @@ class AlbumListBuilderTest {
|
|||||||
coverPhotoId = null,
|
coverPhotoId = null,
|
||||||
coverThumbUrl = null,
|
coverThumbUrl = null,
|
||||||
coverMediumUrl = null,
|
coverMediumUrl = null,
|
||||||
photosCount = 0,
|
photosCount = photos,
|
||||||
membersCount = 1,
|
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,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
itemCount = 3,
|
itemCount = count,
|
||||||
coverUri = null,
|
coverUri = null,
|
||||||
relativePath = "DCIM/$name/",
|
relativePath = "DCIM/$name/",
|
||||||
|
updatedAtMillis = updated,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun keys(items: List<AlbumListItem>) = items.map { it.key }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a linked server album is collapsed into the device card`() {
|
fun `a linked server album is collapsed into the device card`() {
|
||||||
val items = AlbumListBuilder.build(
|
val items = AlbumListBuilder.build(
|
||||||
@@ -65,4 +80,110 @@ class AlbumListBuilderTest {
|
|||||||
assertEquals(null, deviceItem.linkedServerTitle)
|
assertEquals(null, deviceItem.linkedServerTitle)
|
||||||
assertEquals(SyncMode.COPY, deviceItem.mode)
|
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
|
package com.galo.android.ui.albums
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.Album
|
||||||
|
import com.galo.android.domain.model.AlbumSortChoice
|
||||||
import com.galo.android.domain.model.DeviceAlbum
|
import com.galo.android.domain.model.DeviceAlbum
|
||||||
import com.galo.android.domain.model.SyncMode
|
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_IMAGE
|
||||||
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
|
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
|
||||||
import com.galo.android.ui.theme.GaloTheme
|
import com.galo.android.ui.theme.GaloTheme
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -24,7 +32,12 @@ class AlbumsContentTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
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,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
description = "",
|
description = "",
|
||||||
@@ -32,9 +45,9 @@ class AlbumsContentTest {
|
|||||||
coverPhotoId = null,
|
coverPhotoId = null,
|
||||||
coverThumbUrl = null,
|
coverThumbUrl = null,
|
||||||
coverMediumUrl = null,
|
coverMediumUrl = null,
|
||||||
photosCount = 4,
|
photosCount = photos,
|
||||||
membersCount = 2,
|
membersCount = 2,
|
||||||
updatedAt = null,
|
updatedAt = updated,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun device(id: String, name: String, count: Int = 3) = DeviceAlbum(
|
private fun device(id: String, name: String, count: Int = 3) = DeviceAlbum(
|
||||||
@@ -56,6 +69,8 @@ class AlbumsContentTest {
|
|||||||
onOpenProfile = {},
|
onOpenProfile = {},
|
||||||
onRefresh = {},
|
onRefresh = {},
|
||||||
onLogout = {},
|
onLogout = {},
|
||||||
|
onSortChange = {},
|
||||||
|
onReversedChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,4 +162,58 @@ class AlbumsContentTest {
|
|||||||
}
|
}
|
||||||
compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed()
|
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 android.content.Context
|
||||||
import androidx.test.core.app.ApplicationProvider
|
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.data.repo.LiveAlbumSource
|
||||||
import com.galo.android.di.AppContainer
|
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.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.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
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
|
@Test
|
||||||
fun `the personal stream starts once and is not restarted by reading state`() {
|
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 live = CountingLive()
|
||||||
|
|
||||||
val viewModel = AlbumsViewModel(container, live)
|
val viewModel = AlbumsViewModel(container, live, FakeSort())
|
||||||
viewModel.state.value
|
viewModel.state.value
|
||||||
viewModel.state.value
|
viewModel.state.value
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
|
|
||||||
assertEquals(1, live.runs)
|
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