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
18 changes: 8 additions & 10 deletions .github/workflows/google-play.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 32 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<Favorite>.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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}
73 changes: 70 additions & 3 deletions app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -213,7 +227,11 @@ fun CoilApp(appViewModel: AppViewModel) {
composable(Destination.FAVOURITES.route) {
val viewModel = hiltViewModel<FavoritesViewModel>()
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) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading