Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
93 changes: 69 additions & 24 deletions app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<string name="action_favourites_sort">Favoriten sortieren</string>
<string name="favourites_sort_manual">Eigene Reihenfolge</string>
<string name="favourites_sort_name">A–Z</string>
<string name="favourites_sort_name_desc">Z–A</string>

<string name="boxes_title">Boxen</string>
<string name="boxes_switch">Box wechseln</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
<string name="action_favourites_sort">Ordenar favoritos</string>
<string name="favourites_sort_manual">Orden personalizado</string>
<string name="favourites_sort_name">A–Z</string>
<string name="favourites_sort_name_desc">Z–A</string>

<string name="boxes_title">Cajas</string>
<string name="boxes_switch">Cambiar de caja</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
<string name="action_favourites_sort">Trier les favoris</string>
<string name="favourites_sort_manual">Ordre personnalisé</string>
<string name="favourites_sort_name">A–Z</string>
<string name="favourites_sort_name_desc">Z–A</string>

<string name="boxes_title">Box</string>
<string name="boxes_switch">Changer de box</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<string name="action_favourites_sort">Favorieten sorteren</string>
<string name="favourites_sort_manual">Eigen volgorde</string>
<string name="favourites_sort_name">A–Z</string>
<string name="favourites_sort_name_desc">Z–A</string>

<string name="boxes_title">Boxen</string>
<string name="boxes_switch">Andere box kiezen</string>
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,14 @@
<string name="action_favourites_show_grid">Show as covers</string>
<!-- The other top bar action on the favourites tab: a menu naming the order the entries
are in, with the current one ticked. "Custom order" is the arrangement made with move
up and move down, which starts out as the order things were saved in. -->
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. -->
<string name="action_favourites_sort">Sort favourites</string>
<string name="favourites_sort_manual">Custom order</string>
<string name="favourites_sort_name">A–Z</string>
<string name="favourites_sort_name_desc">Z–A</string>

<!-- Box switcher and box management -->
<string name="boxes_title">Boxes</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Favorite>.ordered(sort: FavoritesSort): List<Favorite> = 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<Favorite> = compareBy(Collator.getInstance()) { it.label }
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading