diff --git a/.github/workflows/google-play.yml b/.github/workflows/google-play.yml index cb83618..f6e9e48 100644 --- a/.github/workflows/google-play.yml +++ b/.github/workflows/google-play.yml @@ -116,13 +116,11 @@ jobs: tracks: ${{ inputs.track || 'internal' }} whatsNewDirectory: whatsnew/ mappingFile: release-assets/mapping.txt - # Commit the edit without submitting it for review, and press "Send for review" in the - # Play Console instead. Without this, `Edits.commit` refuses an app whose changes it may - # not submit on its own — "Changes cannot be sent for review automatically. Please set - # the query parameter changesNotSentForReview to true" — which is every upload to an app - # that has not had a release reviewed yet, and any upload made while a console-side edit - # is still pending. The internal track needs no review at all, so nothing is held up - # there; a production release is, until someone submits it. Drop this once the listing - # has been through review and nothing is pending, if the manual step becomes the bigger - # risk of the two. - changesNotSentForReview: true + # `changesNotSentForReview` is deliberately left at its default (false). This app sends + # changes for review automatically, and `Edits.commit` then rejects the parameter being + # set at all: "Changes are sent for review automatically. The query parameter + # changesNotSentForReview must not be set." v1.2.2 failed exactly there, after the AAB + # had already uploaded. Only set it to true if Play starts refusing the opposite way + # ("Changes cannot be sent for review automatically. Please set the query parameter + # changesNotSentForReview to true") — the two states are mutually exclusive, so the flag + # is never safe to leave on speculatively. diff --git a/AGENTS.md b/AGENTS.md index 6674978..2b66da6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,13 +73,17 @@ condensed map of it, not a replacement. See "Implementation status" for what is handles only the AAB, mapping and release notes. The track reaches that action as `tracks` — the singular `track` is deprecated there, and setting both is a hard error. This workflow's own input stays singular because it passes exactly one, and it defaults twice over: the action uploads to - **production** when neither input is given, which is not a default to reach by accident. It also - passes `changesNotSentForReview: true`, because `Edits.commit` refuses to commit an edit it may - not submit for review itself — "Changes cannot be sent for review automatically. Please set the - query parameter changesNotSentForReview to true", which is every upload to an app that has not - had a release reviewed yet, and any upload racing a pending console-side edit. The cost is that - a production upload waits for someone to press **Send for review** in the Play Console; the - internal track needs no review, so nothing waits there. + **production** when neither input is given, which is not a default to reach by accident. It + leaves `changesNotSentForReview` at its default (false): this app sends changes for review + automatically, and `Edits.commit` then rejects the parameter outright — "Changes are sent for + review automatically. The query parameter changesNotSentForReview must not be set." Setting it + to `true` is only correct for an app in the opposite state, which refuses with "Changes cannot + be sent for review automatically. Please set the query parameter changesNotSentForReview to + true" (an app whose first release has not been reviewed, or an upload racing a pending + console-side edit). The two states are mutually exclusive, so the flag must never be left on + speculatively — v1.2.2's deploy failed at the commit step that way, with the AAB already + uploaded. A failed commit abandons the edit, so the same `versionCode` can be re-uploaded by + re-dispatching the workflow with the same tag. - `pages.yml` — deploys `docs/pages/` via Jekyll to GitHub Pages on push to `main` (path-filtered to `docs/pages/**`). GitHub Pages must be enabled in repo settings with source "GitHub Actions", the custom domain must be set there, and `coilforphoniebox.app` DNS must point at GitHub Pages. @@ -623,7 +627,8 @@ Three levels, and the distinction matters: Kotlin 2.0.21 cannot read. Upgrading it means upgrading Kotlin and the Compose compiler too. Covered so far: the whole app on three devices (player light and dark, library, favourites in both -layouts, settings top and lower half, box management, one box's page, offline, onboarding), the +layouts and sorted A–Z, settings top and lower half, box management, one box's page, offline, +onboarding), the player screen (playing, paused, idle, web radio, sleep timer, a cover still resolving, light and dark), the library screen (folders, tracks, albums, search, no results, empty, light and dark) and the chrome components. @@ -632,8 +637,10 @@ The favourites goldens carry **two entries with a `coverFile` and one without**, matter: a cover from the box has to render, and a favourite it has no artwork for has to get the stand-in the app picks from its own set. `favourites_compact_*` reaches the list layout by *clicking* the top bar action rather than presetting the stored preference, so a toggle that stopped switching fails the -test instead of quietly capturing the same picture twice. `Fixtures.albums` does the same for the -album grid, four of six with artwork. +test instead of quietly capturing the same picture twice. `favourites_sorted_*` reaches A–Z the +same way, through the top bar menu — and `Fixtures.favorites` is deliberately saved in an order +that is *not* alphabetical, so a sort that stopped sorting cannot keep that picture either. +`Fixtures.albums` does the same for the album grid, four of six with artwork. `StoreFixtures.favorites` carries covers on three of four, and the fourth now draws a stand-in rather than a placeholder icon — no tile in that listing image can come out empty any more. The mix @@ -978,6 +985,21 @@ still needs doing, in rough order of importance: action the other has. It also rides in the settings backup, which did **not** bump `FORMAT_VERSION`: an older build that drops the field loses a layout preference, not the ability to play anything. Same for the `coverFile` now exported per favourite. +- **The favourites tab also has two orders, and that switch is in the top bar as well.** + `FavoritesSort` in `AppSettings` is either `MANUAL` — the arrangement made with move up and move + down, which before any move is the order things were saved in — or `NAME`. `MANUAL` stays the + default: a wall of covers is something a parent arranges, and an update that reshuffled it would + take work away. Decisions worth keeping: sorting **never writes `sortIndex`**, so switching back + to `MANUAL` brings the arrangement out exactly as it was; move up and move down are offered only + while the tab is in `MANUAL` (`FavoriteEntry.onMove` is nullable for it), because in a sorted + list they would write an arrangement nobody can see and read as a tile that refuses to budge; + the order is applied by `List.ordered` in `core-domain` rather than by a second DAO + query, which keeps one `ORDER BY` in `FavoriteDao` and puts the collation somewhere testable; + and it collates with `java.text.Collator`, not `compareTo` — the app ships in five languages and + `"Ärger" > "Zug"` is true of every code-point comparison and of no user's expectation. Unlike + the layout it is a **menu with the current order ticked, not a toggle**: the shape on screen + answers "what is it now?" by itself, but a grid sorted A–Z and one a user happened to arrange + that way look identical. It rides in the settings backup on the same terms as the layout. - **Long press in the library opens a context menu, it does not toggle a favourite.** The plan (§14, phase 2) has long press as the way to favourite something, which is undiscoverable and was the only way to do it. Every library row and album cell now carries a ⋮ button, and both it and a long diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ea6d8..51f7dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build ## [Unreleased] +## [1.2.3] - 2026-09-08 + +### Added +- **Favourites can be sorted A–Z.** The tab showed them in the order they were saved and nothing + in the top bar said otherwise, so a collection that had grown past a screenful had no way to be + scanned by name — the only way to move a tile was two entries buried in its own ⋮ menu, one step + at a time. A sort action now sits beside the layout switch, with the current order ticked: the + hand-made arrangement, or alphabetical. Sorting leaves the arrangement untouched, so switching + back brings it out exactly as it was, and move up and move down are offered only while the tab + is in that order — where they can actually be seen to work + ## [1.2.2] - 2026-08-27 ### Fixed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77f4df4..29cc724 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "app.coilforphoniebox" minSdk = 26 targetSdk = 36 - versionCode = 10 - versionName = "1.2.2" + versionCode = 11 + versionName = "1.2.3" } androidResources { diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt index 94b434b..bbef93f 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt @@ -7,6 +7,7 @@ import app.coilforphoniebox.domain.model.Box import app.coilforphoniebox.domain.model.ConnectionState import app.coilforphoniebox.domain.model.ConnectionTestResult import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.PlayerStatus import app.coilforphoniebox.domain.repository.BoxRepository import app.coilforphoniebox.domain.repository.LibraryRepository @@ -154,4 +155,15 @@ class AppViewModel @Inject constructor( } viewModelScope.launch { settings.setFavoritesLayout(next) } } + + /** + * Chooses what order the favourites tab shows. + * + * Here for the same reason as [toggleFavoritesLayout]: the control is in the top bar and + * the choice is stored. Unlike the layout it is not a toggle — the two orders are not + * opposites of one shape, and "sorted A–Z" has to be readable as the state it is in. + */ + fun setFavoritesSort(sort: FavoritesSort) { + viewModelScope.launch { settings.setFavoritesSort(sort) } + } } diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt index a7cf32d..58306d6 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt @@ -1,18 +1,23 @@ package app.coilforphoniebox.ui import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.automirrored.rounded.ViewList +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.LibraryMusic import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,6 +37,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -43,6 +50,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import app.coilforphoniebox.R import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.ui.boxes.AddBoxScreen import app.coilforphoniebox.ui.boxes.AddBoxViewModel import app.coilforphoniebox.ui.boxes.BoxDetailScreen @@ -143,10 +151,16 @@ fun CoilApp(appViewModel: AppViewModel) { } } }, - // Only the favourites tab has anything to put here. The action shows what a - // tap would switch *to*, so the icon and its label describe the same thing. + // Only the favourites tab has anything to put here: how its entries are + // ordered, and what shape they take. The layout action shows what a tap would + // switch *to*, so the icon and its label describe the same thing. actions = { if (currentRoute == Destination.FAVOURITES.route) { + FavoritesSortAction( + current = state.settings.favoritesSort, + onSelect = appViewModel::setFavoritesSort, + ) + val list = state.settings.favoritesLayout == FavoritesLayout.LIST IconButton(onClick = appViewModel::toggleFavoritesLayout) { Icon( @@ -213,7 +227,11 @@ fun CoilApp(appViewModel: AppViewModel) { composable(Destination.FAVOURITES.route) { val viewModel = hiltViewModel() SnackbarMessages(viewModel.messages, snackbarHostState) - FavoritesScreen(viewModel, layout = state.settings.favoritesLayout) + FavoritesScreen( + viewModel = viewModel, + layout = state.settings.favoritesLayout, + sort = state.settings.favoritesSort, + ) } composable(Destination.SETTINGS.route) { @@ -276,6 +294,55 @@ fun CoilApp(appViewModel: AppViewModel) { } } +/** + * The favourites tab's order, as a menu rather than a toggle. + * + * A toggle can only say what a tap switches *to*, which works for the layout — the shape on + * screen answers "and what is it now?" by itself. Order cannot: a grid sorted A–Z and a grid + * a user happens to have arranged that way look identical, so the state has to be written + * down. Hence a menu, with the current order ticked. + */ +@Composable +private fun FavoritesSortAction(current: FavoritesSort, onSelect: (FavoritesSort) -> Unit) { + var menuOpen by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Sort, + contentDescription = stringResource(R.string.action_favourites_sort), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + FavoritesSort.entries.forEach { sort -> + DropdownMenuItem( + text = { Text(stringResource(sort.labelRes)) }, + // The tick is decorative; `selected` is what makes the choice audible, + // and a menu whose state only exists as a picture is no state at all. + leadingIcon = { + if (sort == current) { + Icon(imageVector = Icons.Rounded.Check, contentDescription = null) + } + }, + onClick = { + menuOpen = false + onSelect(sort) + }, + modifier = Modifier.semantics { selected = sort == current }, + ) + } + } + } +} + +@get:StringRes +private val FavoritesSort.labelRes: Int + get() = when (this) { + FavoritesSort.MANUAL -> R.string.favourites_sort_manual + FavoritesSort.NAME -> R.string.favourites_sort_name + } + @Composable private fun BottomBar(navController: NavHostController, selected: Destination?) { NavigationBar { diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt index 951d1b9..a813e88 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt @@ -48,20 +48,24 @@ import app.coilforphoniebox.domain.model.Box as PhonieBox import app.coilforphoniebox.domain.model.Favorite import app.coilforphoniebox.domain.model.FavoriteType import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort +import app.coilforphoniebox.domain.model.ordered import app.coilforphoniebox.ui.components.CoverArt import app.coilforphoniebox.ui.components.EmptyState import app.coilforphoniebox.ui.components.shareLink /** - * The favourites tab, in one of two layouts (§7.2). + * The favourites tab, in one of two layouts (§7.2) and one of two orders. * - * [layout] comes from settings via the shell rather than from this screen's own view model, - * because the control that changes it lives in the top bar — one preference, one owner. + * [layout] and [sort] come from settings via the shell rather than from this screen's own + * view model, because the controls that change them live in the top bar — one preference, + * one owner. */ @Composable fun FavoritesScreen( viewModel: FavoritesViewModel, layout: FavoritesLayout, + sort: FavoritesSort, modifier: Modifier = Modifier, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -80,6 +84,13 @@ fun FavoritesScreen( // item block — which keeps the two from drifting apart in what they can do. val compact = layout == FavoritesLayout.LIST + val entries = remember(state.favorites, sort) { state.favorites.ordered(sort) } + + // Move up and move down are offered only while the list is in the user's own order. + // Sorted alphabetically they would write a new arrangement nobody can see, and read as + // broken — the tile would not budge. + val movable = sort == FavoritesSort.MANUAL + LazyVerticalGrid( columns = if (compact) GridCells.Fixed(1) else GridCells.Adaptive(minSize = 148.dp), contentPadding = if (compact) PaddingValues(vertical = 8.dp) else PaddingValues(12.dp), @@ -87,7 +98,7 @@ fun FavoritesScreen( verticalArrangement = Arrangement.spacedBy(if (compact) 2.dp else 12.dp), modifier = modifier, ) { - items(state.favorites, key = { it.id }) { favorite -> + items(entries, key = { it.id }) { favorite -> // Cover art is fetched for the entries that are actually on screen, never for // the whole table at once — see FavoritesViewModel.ensureCover. LaunchedEffect(favorite.id, favorite.coverFile) { viewModel.ensureCover(favorite) } @@ -102,7 +113,11 @@ fun FavoritesScreen( onPlay = { viewModel.play(favorite) }, onRemove = { viewModel.remove(favorite) }, onPin = { coverUrl -> viewModel.requestPin(favorite, coverUrl) }, - onMove = { up -> viewModel.move(favorite, up) }, + onMove = if (movable) { + { up -> viewModel.move(favorite, up) } + } else { + null + }, onLinkCopied = viewModel::onLinkCopied, ) } @@ -128,7 +143,8 @@ private fun FavoriteEntry( onPlay: () -> Unit, onRemove: () -> Unit, onPin: (String?) -> Unit, - onMove: (Boolean) -> Unit, + /** Null while the tab is sorted rather than hand-arranged — see [FavoritesScreen]. */ + onMove: ((Boolean) -> Unit)?, onLinkCopied: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -254,7 +270,7 @@ private fun FavoriteMenu( label: String, link: String?, onPin: () -> Unit, - onMove: (Boolean) -> Unit, + onMove: ((Boolean) -> Unit)?, onRemove: () -> Unit, onLinkCopied: () -> Unit, ) { @@ -290,20 +306,22 @@ private fun FavoriteMenu( }, ) } - DropdownMenuItem( - text = { Text(stringResource(R.string.action_move_up)) }, - onClick = { - onDismiss() - onMove(true) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_move_down)) }, - onClick = { - onDismiss() - onMove(false) - }, - ) + if (onMove != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_move_up)) }, + onClick = { + onDismiss() + onMove(true) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_move_down)) }, + onClick = { + onDismiss() + onMove(false) + }, + ) + } DropdownMenuItem( text = { Text(stringResource(R.string.action_remove)) }, onClick = { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 54826e8..5641dd2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -91,6 +91,9 @@ Dieser Launcher unterstützt keine Verknüpfungen auf dem Startbildschirm. Als Liste anzeigen Als Cover anzeigen + Favoriten sortieren + Eigene Reihenfolge + A–Z Boxen Box wechseln diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c9617a6..7c38f94 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -92,6 +92,9 @@ Este lanzador no admite accesos directos en la pantalla de inicio. Ver como lista Ver las portadas + Ordenar favoritos + Orden personalizado + A–Z Cajas Cambiar de caja diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c9553f4..8461f2d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -96,6 +96,9 @@ Ce lanceur ne prend pas en charge les raccourcis sur l\'écran d\'accueil. Afficher en liste Afficher les pochettes + Trier les favoris + Ordre personnalisé + A–Z Box Changer de box diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0f68954..5b3856a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -91,6 +91,9 @@ Deze launcher ondersteunt geen snelkoppelingen op het startscherm. Als lijst weergeven Als hoezen weergeven + Favorieten sorteren + Eigen volgorde + A–Z Boxen Andere box kiezen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e72cb2c..b729091 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,12 @@ to, not the one on screen. --> Show as list Show as covers + + Sort favourites + Custom order + A–Z Boxes diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt index 87505f5..1138137 100644 --- a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt @@ -102,6 +102,23 @@ abstract class AppScreenshotTest : ScreenshotTest() { captureRoot("app/favourites_compact_$device") } + /** + * The same favourites in alphabetical order, reached through the top bar menu rather than + * by presetting the stored preference — for the same reason as `favourites_compact_*`. + * The fixture's three labels sort into a different order than they were saved in, so a + * sort that stopped sorting cannot quietly keep this picture. + */ + @Test + fun favourites_sorted() { + showApp() + navigateTo(R.string.nav_favourites) + compose.onNodeWithContentDescription(string(R.string.action_favourites_sort)).performClick() + compose.waitForIdle() + compose.onNodeWithText(string(R.string.favourites_sort_name)).performClick() + compose.waitForIdle() + captureRoot("app/favourites_sorted_$device") + } + @Test fun settings() { showApp() diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt index 0b2ce90..25568f4 100644 --- a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/FakeRepositories.kt @@ -6,6 +6,7 @@ import app.coilforphoniebox.domain.model.ConnectionState import app.coilforphoniebox.domain.model.ConnectionTestResult import app.coilforphoniebox.domain.model.Favorite import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.FolderContent import app.coilforphoniebox.domain.model.LibraryAlbum import app.coilforphoniebox.domain.model.LibraryIndexResult @@ -283,6 +284,10 @@ class FakeSettingsRepository(settings: AppSettings = AppSettings()) : SettingsRe state.value = state.value.copy(favoritesLayout = layout) } + override suspend fun setFavoritesSort(sort: FavoritesSort) { + state.value = state.value.copy(favoritesSort = sort) + } + override suspend fun setLoadExternalCoverArt(enabled: Boolean) { state.value = state.value.copy(loadExternalCoverArt = enabled) } diff --git a/app/src/testDebug/screenshots/app/favourites_compact_phone.png b/app/src/testDebug/screenshots/app/favourites_compact_phone.png index 42035b7..0159918 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_compact_phone.png and b/app/src/testDebug/screenshots/app/favourites_compact_phone.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_compact_small.png b/app/src/testDebug/screenshots/app/favourites_compact_small.png index 78bd7ee..7dad7cf 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_compact_small.png and b/app/src/testDebug/screenshots/app/favourites_compact_small.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_compact_tablet.png b/app/src/testDebug/screenshots/app/favourites_compact_tablet.png index 6404044..02d5f6a 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_compact_tablet.png and b/app/src/testDebug/screenshots/app/favourites_compact_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_phone.png b/app/src/testDebug/screenshots/app/favourites_phone.png index c37a0ae..6ec4cc3 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_phone.png and b/app/src/testDebug/screenshots/app/favourites_phone.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_small.png b/app/src/testDebug/screenshots/app/favourites_small.png index 6b35083..6c5a03b 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_small.png and b/app/src/testDebug/screenshots/app/favourites_small.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_sorted_phone.png b/app/src/testDebug/screenshots/app/favourites_sorted_phone.png new file mode 100644 index 0000000..3c3c136 Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_sorted_phone.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_sorted_small.png b/app/src/testDebug/screenshots/app/favourites_sorted_small.png new file mode 100644 index 0000000..12e584e Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_sorted_small.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_sorted_tablet.png b/app/src/testDebug/screenshots/app/favourites_sorted_tablet.png new file mode 100644 index 0000000..c3749f8 Binary files /dev/null and b/app/src/testDebug/screenshots/app/favourites_sorted_tablet.png differ diff --git a/app/src/testDebug/screenshots/app/favourites_tablet.png b/app/src/testDebug/screenshots/app/favourites_tablet.png index a14b185..9562d7b 100644 Binary files a/app/src/testDebug/screenshots/app/favourites_tablet.png and b/app/src/testDebug/screenshots/app/favourites_tablet.png differ diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt index 50e72d3..7484752 100644 --- a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/BackupRepositoryImpl.kt @@ -9,6 +9,7 @@ import app.coilforphoniebox.domain.model.Box import app.coilforphoniebox.domain.model.Favorite import app.coilforphoniebox.domain.model.FavoriteType import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.LibraryProvider import app.coilforphoniebox.domain.model.SessionMode import app.coilforphoniebox.domain.model.ThemeMode @@ -87,6 +88,7 @@ class BackupRepositoryImpl @Inject constructor( val dynamicColor: Boolean = false, val sessionMode: String = SessionMode.APP_ONLY.name, val favoritesLayout: String = FavoritesLayout.GRID.name, + val favoritesSort: String = FavoritesSort.MANUAL.name, /** * Defaults to false so a backup written before this setting existed imports as * "off" — the safe reading, since the file cannot say the user ever opted in. @@ -129,6 +131,7 @@ class BackupRepositoryImpl @Inject constructor( dynamicColor = current.dynamicColor, sessionMode = current.sessionMode.name, favoritesLayout = current.favoritesLayout.name, + favoritesSort = current.favoritesSort.name, loadExternalCoverArt = current.loadExternalCoverArt, ), ) @@ -205,6 +208,10 @@ class BackupRepositoryImpl @Inject constructor( FavoritesLayout.entries.firstOrNull { it.name == file.settings.favoritesLayout } ?: FavoritesLayout.GRID, ) + settings.setFavoritesSort( + FavoritesSort.entries.firstOrNull { it.name == file.settings.favoritesSort } + ?: FavoritesSort.MANUAL, + ) settings.setLoadExternalCoverArt(file.settings.loadExternalCoverArt) } @@ -219,9 +226,10 @@ class BackupRepositoryImpl @Inject constructor( * an older build would import a `TRACK` row without its URL, which is a * favourite that cannot play. Refusing the file says so instead. * - * `coverFile` and `favoritesLayout` arrived later and deliberately did *not* bump - * it: an older build that drops them loses a cover it can resolve again from the - * box and a layout preference, not the ability to play anything. + * `coverFile`, `favoritesLayout` and `favoritesSort` arrived later and deliberately + * did *not* bump it: an older build that drops them loses a cover it can resolve + * again from the box and two view preferences for one tab, not the ability to play + * anything. */ const val FORMAT_VERSION = 2 } diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt index abbea6a..1af20fa 100644 --- a/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/repository/SettingsRepositoryImpl.kt @@ -3,6 +3,7 @@ package app.coilforphoniebox.data.repository import app.coilforphoniebox.data.settings.SettingsStore import app.coilforphoniebox.domain.model.AppSettings import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.SessionMode import app.coilforphoniebox.domain.model.ThemeMode import app.coilforphoniebox.domain.repository.SettingsRepository @@ -28,6 +29,8 @@ class SettingsRepositoryImpl @Inject constructor( override suspend fun setFavoritesLayout(layout: FavoritesLayout) = store.setFavoritesLayout(layout) + override suspend fun setFavoritesSort(sort: FavoritesSort) = store.setFavoritesSort(sort) + override suspend fun setLoadExternalCoverArt(enabled: Boolean) = store.setLoadExternalCoverArt(enabled) diff --git a/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt b/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt index f679009..6423055 100644 --- a/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt +++ b/core-data/src/main/kotlin/app/coilforphoniebox/data/settings/SettingsStore.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import app.coilforphoniebox.domain.model.AppSettings import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.SessionMode import app.coilforphoniebox.domain.model.ThemeMode import kotlinx.coroutines.flow.Flow @@ -29,6 +30,7 @@ class SettingsStore @Inject constructor( dynamicColor = prefs[DYNAMIC_COLOR] ?: false, sessionMode = prefs[SESSION_MODE].toSessionMode(), favoritesLayout = prefs[FAVORITES_LAYOUT].toFavoritesLayout(), + favoritesSort = prefs[FAVORITES_SORT].toFavoritesSort(), loadExternalCoverArt = prefs[LOAD_EXTERNAL_COVER_ART] ?: false, activeBoxId = prefs[ACTIVE_BOX_ID], onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false, @@ -45,6 +47,8 @@ class SettingsStore @Inject constructor( suspend fun setFavoritesLayout(layout: FavoritesLayout) = put(FAVORITES_LAYOUT, layout.name) + suspend fun setFavoritesSort(sort: FavoritesSort) = put(FAVORITES_SORT, sort.name) + suspend fun setLoadExternalCoverArt(enabled: Boolean) = put(LOAD_EXTERNAL_COVER_ART, enabled) suspend fun setActiveBoxId(boxId: String?) { @@ -69,11 +73,15 @@ class SettingsStore @Inject constructor( private fun String?.toFavoritesLayout(): FavoritesLayout = this?.let { name -> FavoritesLayout.entries.firstOrNull { it.name == name } } ?: FavoritesLayout.GRID + private fun String?.toFavoritesSort(): FavoritesSort = + this?.let { name -> FavoritesSort.entries.firstOrNull { it.name == name } } ?: FavoritesSort.MANUAL + private companion object { val THEME_MODE = stringPreferencesKey("theme_mode") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val SESSION_MODE = stringPreferencesKey("session_mode") val FAVORITES_LAYOUT = stringPreferencesKey("favorites_layout") + val FAVORITES_SORT = stringPreferencesKey("favorites_sort") val LOAD_EXTERNAL_COVER_ART = booleanPreferencesKey("load_external_cover_art") val ACTIVE_BOX_ID = stringPreferencesKey("active_box_id") val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt index d3f1751..722935a 100644 --- a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/AppSettings.kt @@ -22,6 +22,20 @@ enum class SessionMode { OFF, APP_ONLY, AUTOMATIC } */ enum class FavoritesLayout { GRID, LIST } +/** + * What order the favourites tab shows its entries in. + * + * [MANUAL] is the default: the arrangement the user made with move up and move down, which + * before any move is the order they were saved in. It stays the default because a wall of + * covers is something a parent arranges — the most-played tile goes top left — and an update + * that reshuffled that arrangement would take work away rather than add any. + * + * [NAME] sorts by label instead, for a collection large enough that finding a title matters + * more than where it sits. It does not touch [Favorite.sortIndex], so switching back to + * [MANUAL] brings the arrangement back exactly as it was. + */ +enum class FavoritesSort { MANUAL, NAME } + /** Global settings. Everything box-specific lives on [Box] instead (§7.2). */ data class AppSettings( val themeMode: ThemeMode = ThemeMode.SYSTEM, @@ -29,6 +43,7 @@ data class AppSettings( val dynamicColor: Boolean = false, val sessionMode: SessionMode = SessionMode.APP_ONLY, val favoritesLayout: FavoritesLayout = FavoritesLayout.GRID, + val favoritesSort: FavoritesSort = FavoritesSort.MANUAL, /** * Whether cover art may be fetched from somewhere other than the box. * diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt index 75a6be2..06497c3 100644 --- a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/model/Favorite.kt @@ -1,5 +1,7 @@ package app.coilforphoniebox.domain.model +import java.text.Collator + enum class FavoriteType { FOLDER, ALBUM, TRACK } /** @@ -84,3 +86,20 @@ data class Favorite( } } } + +/** + * The same favourites in the order [sort] asks for. + * + * [FavoritesSort.MANUAL] is the list as it arrives from the database, which is already the + * user's own order — the repository sorts on `sortIndex`, so re-sorting here would only be + * a chance to disagree with it. + * + * The alphabetical case uses a [Collator] rather than comparing strings: the app ships in + * five languages, and `"Ärger" > "Zug"` is true of every code-point comparison and of no + * user's expectation. The collator is built per call because it is not thread safe, and + * sorting a favourites list is not something that happens in a loop. + */ +fun List.ordered(sort: FavoritesSort): List = when (sort) { + FavoritesSort.MANUAL -> this + FavoritesSort.NAME -> sortedWith(compareBy(Collator.getInstance()) { it.label }) +} diff --git a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt index 29796b8..ccb7dab 100644 --- a/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt +++ b/core-domain/src/main/kotlin/app/coilforphoniebox/domain/repository/SettingsRepository.kt @@ -2,6 +2,7 @@ package app.coilforphoniebox.domain.repository import app.coilforphoniebox.domain.model.AppSettings import app.coilforphoniebox.domain.model.FavoritesLayout +import app.coilforphoniebox.domain.model.FavoritesSort import app.coilforphoniebox.domain.model.SessionMode import app.coilforphoniebox.domain.model.ThemeMode import kotlinx.coroutines.flow.Flow @@ -19,6 +20,8 @@ interface SettingsRepository { suspend fun setFavoritesLayout(layout: FavoritesLayout) + suspend fun setFavoritesSort(sort: FavoritesSort) + suspend fun setLoadExternalCoverArt(enabled: Boolean) suspend fun setOnboardingComplete(complete: Boolean) diff --git a/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt index 62b6c64..c3df206 100644 --- a/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt +++ b/core-domain/src/test/kotlin/app/coilforphoniebox/domain/model/FavoriteTest.kt @@ -47,4 +47,40 @@ class FavoriteTest { ) assertNull(Favorite(boxId = "b", label = "x", type = FavoriteType.TRACK).toPlayTarget()) } + + /** + * The manual order is whatever the caller was handed — the repository has already sorted + * on `sortIndex`, and sorting again here would only be a chance to disagree with it. + */ + @Test + fun `the manual order is left exactly as it arrived`() { + val list = listOf(named("Zebra"), named("Apple"), named("Möwe")) + + assertEquals(list, list.ordered(FavoritesSort.MANUAL)) + } + + @Test + fun `sorting by name ignores case`() { + val labels = listOf(named("zebra"), named("Apple"), named("bear")) + .ordered(FavoritesSort.NAME) + .map { it.label } + + assertEquals(listOf("Apple", "bear", "zebra"), labels) + } + + /** + * The app ships in five languages, so an umlaut has to sort where a reader expects it and + * not after Z, which is where every code-point comparison puts it. + */ + @Test + fun `sorting by name collates accented letters with their base letter`() { + val labels = listOf(named("Zug"), named("Ärger"), named("Boot")) + .ordered(FavoritesSort.NAME) + .map { it.label } + + assertEquals(listOf("Ärger", "Boot", "Zug"), labels) + } + + private fun named(label: String) = + Favorite(boxId = "box-1", label = label, type = FavoriteType.FOLDER, folder = label) } diff --git a/docs/pages/assets/screenshots/03_favourites.png b/docs/pages/assets/screenshots/03_favourites.png index 0dd557c..8d13162 100644 Binary files a/docs/pages/assets/screenshots/03_favourites.png and b/docs/pages/assets/screenshots/03_favourites.png differ diff --git a/docs/pages/assets/screenshots/tablet10/03_favourites.png b/docs/pages/assets/screenshots/tablet10/03_favourites.png index c1e4888..13dfc39 100644 Binary files a/docs/pages/assets/screenshots/tablet10/03_favourites.png and b/docs/pages/assets/screenshots/tablet10/03_favourites.png differ diff --git a/docs/pages/assets/screenshots/tablet7/03_favourites.png b/docs/pages/assets/screenshots/tablet7/03_favourites.png index 2dad732..2d74ef8 100644 Binary files a/docs/pages/assets/screenshots/tablet7/03_favourites.png and b/docs/pages/assets/screenshots/tablet7/03_favourites.png differ diff --git a/fastlane/metadata/android/de-DE/changelogs/11.txt b/fastlane/metadata/android/de-DE/changelogs/11.txt new file mode 100644 index 0000000..e41537a --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/11.txt @@ -0,0 +1 @@ +Favoriten lassen sich jetzt von A–Z sortieren. Oben im Favoriten-Tab sitzt neben dem Umschalter für die Ansicht eine Schaltfläche zum Sortieren, in der die aktuelle Reihenfolge angehakt ist: die eigene Anordnung oder alphabetisch. Sortieren lässt die eigene Anordnung unberührt – zurückgeschaltet steht sie wieder genau so da. diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..4d3effa --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -0,0 +1 @@ +Favourites can now be sorted A–Z. A sort button sits at the top of the Favourites tab, next to the layout switch, with the current order ticked: your own arrangement, or alphabetical. Sorting leaves your arrangement untouched, so switching back brings it out exactly as you left it. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png index 0dd557c..8d13162 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png index 2dad732..2d74ef8 100644 Binary files a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png and b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png index c1e4888..13dfc39 100644 Binary files a/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03_favourites.png differ diff --git a/fastlane/metadata/android/es-ES/changelogs/11.txt b/fastlane/metadata/android/es-ES/changelogs/11.txt new file mode 100644 index 0000000..61cd5ab --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/11.txt @@ -0,0 +1 @@ +Ahora los favoritos se pueden ordenar de la A a la Z. En la parte superior de la pestaña Favoritos, junto al selector de vista, hay un botón de orden con la opción actual marcada: tu propia disposición o el orden alfabético. Ordenar no altera tu disposición: al volver, la encontrarás igual. diff --git a/fastlane/metadata/android/fr-FR/changelogs/11.txt b/fastlane/metadata/android/fr-FR/changelogs/11.txt new file mode 100644 index 0000000..a69d49d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/11.txt @@ -0,0 +1 @@ +Les favoris peuvent désormais être triés de A à Z. Un bouton de tri se trouve en haut de l'onglet Favoris, à côté du sélecteur d'affichage, avec l'ordre actuel coché : votre propre classement ou l'ordre alphabétique. Le tri ne touche pas à votre classement : en y revenant, vous le retrouvez tel quel. diff --git a/fastlane/metadata/android/nl-NL/changelogs/11.txt b/fastlane/metadata/android/nl-NL/changelogs/11.txt new file mode 100644 index 0000000..5c1a604 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/11.txt @@ -0,0 +1 @@ +Favorieten kunnen nu op A–Z worden gesorteerd. Boven in het tabblad Favorieten staat naast de weergaveknop een sorteerknop, met de huidige volgorde aangevinkt: je eigen volgorde of alfabetisch. Sorteren laat je eigen volgorde ongemoeid – schakel je terug, dan staat alles er weer precies zo bij.