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