diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7dda..73e3e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build ## [Unreleased] +### Added +- **The alphabetical order reverses.** Choosing A–Z again turns it into Z–A, the gesture a sort + control answers to nearly everywhere else, and choosing it once more turns it back. The entry + is labelled with the order the list is actually in rather than with a fixed "A–Z", so a tab + sorted Z–A says so instead of leaving it to be worked out from the tiles — which matters here, + where a sorted arrangement and a hand-made one look identical + ## [1.2.3] - 2026-09-08 ### Added diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt index bbef93f..48f5bbb 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt @@ -160,8 +160,9 @@ class AppViewModel @Inject constructor( * 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. + * the choice is stored. Unlike the layout it takes the order it should switch to rather + * than cycling one itself — the three orders are not a ring, and which one follows the + * current one is a question about the menu's rows, so it is answered there. */ 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 58306d6..11bf7d8 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt @@ -9,6 +9,8 @@ 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.ArrowDownward +import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.LibraryMusic @@ -301,10 +303,18 @@ fun CoilApp(appViewModel: AppViewModel) { * 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. + * + * The three orders are two entries, not three: the alphabetical one reverses when it is + * chosen again, which is the gesture a sort control has almost everywhere else. That would + * normally be the thing this menu exists to avoid — a tap whose result is invisible — so the + * entry is written to answer both questions at once: its label *is* the direction, so the + * ticked row reads "Z–A" once reversed instead of leaving the user to infer it. */ @Composable private fun FavoritesSortAction(current: FavoritesSort, onSelect: (FavoritesSort) -> Unit) { var menuOpen by remember { mutableStateOf(false) } + val descending = current == FavoritesSort.NAME_DESC + val alphabetical = current == FavoritesSort.NAME || descending Box { IconButton(onClick = { menuOpen = true }) { @@ -315,34 +325,69 @@ private fun FavoritesSortAction(current: FavoritesSort, onSelect: (FavoritesSort } 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 }, - ) - } + DropdownMenuItem( + text = { Text(stringResource(R.string.favourites_sort_manual)) }, + // 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 (!alphabetical) { + Icon(imageVector = Icons.Rounded.Check, contentDescription = null) + } + }, + onClick = { + menuOpen = false + onSelect(FavoritesSort.MANUAL) + }, + modifier = Modifier.semantics { selected = !alphabetical }, + ) + + // One entry for both directions, because choosing the alphabetical order again + // is how a list gets reversed nearly everywhere else. The label carries the + // direction rather than a fixed "A–Z" plus an arrow: it reads as the state it is + // in — "Z–A", ticked — and as what a tap does from the other order, which is + // what an order that looks identical on screen to a hand-made one needs. The + // arrow is the affordance for tapping a row that is already ticked, and is + // decorative for the same reason the tick is. + DropdownMenuItem( + text = { + Text( + stringResource( + if (descending) R.string.favourites_sort_name_desc + else R.string.favourites_sort_name, + ), + ) + }, + leadingIcon = { + if (alphabetical) { + Icon(imageVector = Icons.Rounded.Check, contentDescription = null) + } + }, + trailingIcon = { + if (alphabetical) { + Icon( + imageVector = if (descending) { + Icons.Rounded.ArrowDownward + } else { + Icons.Rounded.ArrowUpward + }, + contentDescription = null, + ) + } + }, + onClick = { + menuOpen = false + // From the manual order, the first tap sorts A–Z; from A–Z it reverses. + onSelect( + if (current == FavoritesSort.NAME) FavoritesSort.NAME_DESC + else FavoritesSort.NAME, + ) + }, + modifier = Modifier.semantics { selected = alphabetical }, + ) } } } -@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 a813e88..8fa04e2 100644 --- a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt +++ b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt @@ -55,7 +55,7 @@ import app.coilforphoniebox.ui.components.EmptyState import app.coilforphoniebox.ui.components.shareLink /** - * The favourites tab, in one of two layouts (§7.2) and one of two orders. + * The favourites tab, in one of two layouts (§7.2) and one of three orders. * * [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, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5641dd2..ef7480d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -94,6 +94,7 @@ Favoriten sortieren Eigene Reihenfolge A–Z + Z–A Boxen Box wechseln diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7c38f94..4ba815b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -95,6 +95,7 @@ Ordenar favoritos Orden personalizado A–Z + Z–A 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 8461f2d..66adbd2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -99,6 +99,7 @@ Trier les favoris Ordre personnalisé A–Z + Z–A 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 5b3856a..f10a5f5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -94,6 +94,7 @@ Favorieten sorteren Eigen volgorde A–Z + Z–A Boxen Andere box kiezen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b729091..7fb892c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -177,10 +177,14 @@ Show as covers + up and move down, which starts out as the order things were saved in. The two + alphabetical labels share one menu entry, which reverses when it is chosen again, so + each label has to read as the order the list is in and not as an instruction. Keep + them as short as this: they sit in a dropdown beside a tick and an arrow. --> Sort favourites Custom order A–Z + Z–A Boxes diff --git a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt index 1138137..b36cd3a 100644 --- a/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt +++ b/app/src/testDebug/kotlin/app/coilforphoniebox/screenshot/AppScreenshotTest.kt @@ -1,5 +1,6 @@ package app.coilforphoniebox.screenshot +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -119,6 +120,36 @@ abstract class AppScreenshotTest : ScreenshotTest() { captureRoot("app/favourites_sorted_$device") } + /** + * Choosing the alphabetical order again reverses it. + * + * Asserted rather than captured, unlike every other test here: what this checks is the + * menu's wiring, and a golden taken with the menu closed would look right even if the + * second tap did nothing at all. The reversed *list* is covered where the ordering + * lives, in `FavoriteTest`; what is left to get wrong is the row — whether tapping a + * ticked entry reverses rather than re-selects, and whether it then says so. + */ + @Test + fun favourites_sort_reverses_when_chosen_again() { + 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() + + compose.onNodeWithContentDescription(string(R.string.action_favourites_sort)).performClick() + compose.waitForIdle() + compose.onNodeWithText(string(R.string.favourites_sort_name)).performClick() + compose.waitForIdle() + + // The entry now reads as the order it put the list in, and still holds the tick. + compose.onNodeWithContentDescription(string(R.string.action_favourites_sort)).performClick() + compose.waitForIdle() + compose.onNodeWithText(string(R.string.favourites_sort_name_desc)).assertIsSelected() + } + @Test fun settings() { showApp() 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 722935a..bb3014a 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 @@ -33,8 +33,13 @@ enum class FavoritesLayout { GRID, LIST } * [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. + * + * [NAME_DESC] is the same order reversed, reached by choosing the alphabetical entry again. + * It is a third value rather than a `descending` flag beside the enum so that the order + * stays *one* setting: one DataStore key, one field in the backup file, and a `when` the + * compiler can still check is exhaustive. */ -enum class FavoritesSort { MANUAL, NAME } +enum class FavoritesSort { MANUAL, NAME, NAME_DESC } /** Global settings. Everything box-specific lives on [Box] instead (§7.2). */ data class AppSettings( 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 06497c3..5fe41a2 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 @@ -94,12 +94,18 @@ data class Favorite( * 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 + * The alphabetical cases use 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. + * + * [FavoritesSort.NAME_DESC] reverses that same comparator rather than sorting by a second + * one, so Z–A is exactly A–Z read backwards — including where the collator puts an umlaut. */ fun List.ordered(sort: FavoritesSort): List = when (sort) { FavoritesSort.MANUAL -> this - FavoritesSort.NAME -> sortedWith(compareBy(Collator.getInstance()) { it.label }) + FavoritesSort.NAME -> sortedWith(byLabel()) + FavoritesSort.NAME_DESC -> sortedWith(byLabel().reversed()) } + +private fun byLabel(): Comparator = compareBy(Collator.getInstance()) { it.label } 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 c3df206..f59f5f7 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 @@ -81,6 +81,29 @@ class FavoriteTest { assertEquals(listOf("Ärger", "Boot", "Zug"), labels) } + @Test + fun `sorting by name descending is the ascending order reversed`() { + val list = listOf(named("zebra"), named("Apple"), named("bear")) + + assertEquals( + list.ordered(FavoritesSort.NAME).map { it.label }.reversed(), + list.ordered(FavoritesSort.NAME_DESC).map { it.label }, + ) + } + + /** + * The reversed order has to collate too, and not merely reverse a code-point sort: an + * umlaut belongs beside its base letter from either end. + */ + @Test + fun `sorting by name descending collates accented letters with their base letter`() { + val labels = listOf(named("Ärger"), named("Zug"), named("Boot")) + .ordered(FavoritesSort.NAME_DESC) + .map { it.label } + + assertEquals(listOf("Zug", "Boot", "Ärger"), labels) + } + private fun named(label: String) = Favorite(boxId = "box-1", label = label, type = FavoriteType.FOLDER, folder = label) }