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)
}