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
@@ -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() {