Adds the hide-track capability. Repository wraps QuarantineApi.flag
with the QUARANTINE_FLAG mutation-queue fallback mirroring the
existing unflag pattern. Sheet collects reason (FilterChip row) +
optional notes (OutlinedTextField); reason vocabulary matches the
server wire values (bad_rip / wrong_file / wrong_tags / duplicate /
other) exactly.
Not yet reachable from a user surface — TrackActionsSheet wires it
in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the playlist-append capability end-to-end. Repository does
optimistic Room write at MAX(position)+1 + REST + MutationQueue
fallback (PLAYLIST_APPEND kind from the previous commit). Sheet
lists user-owned playlists for the menu's "Add to playlist…" item;
not yet reachable from a user surface — TrackActionsSheet wires it
in a later commit.
Also adds maxPosition + insertOrIgnore to CachedPlaylistTrackDao
to support the optimistic write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the offline mutation queue with the two new write kinds the
TrackActions menu produces — appending a track to a playlist, and
flagging a track for quarantine. Replayer re-fires with idempotent
server semantics; payload data classes mirror Flutter's wire shapes.
Also adds PlaylistsApi.appendTracks since the replayer needs the
Retrofit surface in the same change set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the three menu-driven player actions that back the TrackActions
overflow sheet. playNext/enqueue insert without disturbing playback
state; startRadio replaces the queue from GET /api/radio?seed_track=.
RadioController owns the API call so PlayerController stays Retrofit-free.
Endpoint is GET /api/radio?seed_track= (verified against
flutter_client/lib/api/endpoints/radio.dart), not the POST-with-path-
param shape originally drafted in the spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track rows in the Liked tab had .clickable(enabled = false) with a
"Phase 9" deferral comment, silently breaking the most common
interaction on the list. Mirror the HistoryTab pattern: inject
PlayerController, expose playTracks(list, index), and route taps
through to PlayerController.setQueue with source = "liked". Row
clickability is gated on streamUrl so rows that can't be played
stay inert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The isAdmin parameter on MainAppBarActions defaulted to false and no
call site passed it, so the Admin overflow item never appeared for
actual admins — leaving the admin section unreachable from normal UI.
Move the lookup into a tiny AppBarActionsViewModel that reads
AuthController.currentUser, and drop the parameter from the public
signature so future call sites can't recreate the dead-param hazard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MiniPlayer body hit 66 lines after the AsyncImage branch added in
f3ee182. Pulled the cover Box into its own MiniCover composable —
takes coverUrl + contentDescription, identical surfaceVariant
background + Lucide.Music fallback as before. MiniPlayer body
drops back to ~45 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more "shows the placeholder icon when a cover exists" spots
fixed. Same patterns as Phase 124 (AlbumCard) and earlier track-row
covers — uses the existing displayCoverUrl / TrackRef.coverUrl
properties that route through BaseUrlInterceptor + Coil.
Modified:
- library/ui/AlbumDetailScreen.kt — AlbumCover() branches on
`album.id.isEmpty()` instead of `coverUrl.isEmpty()`, paints
via album.displayCoverUrl. Same cached-only-album story as
AlbumCard.
- player/ui/MiniPlayer.kt — drops the "placeholder until 5.x"
comment, renders track.coverUrl via AsyncImage with the
Lucide.Music fallback. Cover box gets a surfaceVariant
background so failed loads degrade to a tinted square. Picks
up cover for the now-playing track in the persistent mini bar.
- player/ui/NowPlayingScreen.kt — renames CoverPlaceholder() →
NowPlayingCover(coverUrl, contentDescription). The full-screen
player's large 320dp cover now shows actual art instead of a
96dp music glyph. Same surfaceVariant + fallback pattern.
Search track results stay the lone remaining cover-less surface;
splitting Search's generic TextRow for per-track covers is the
biggest remaining polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cached_albums → AlbumRef mapper drops coverUrl (only the
coverPath column is stored; the regular API's cover_url isn't
mirrored). Result: AlbumCards rendered from cache — Library Albums
tab, ArtistDetail album grid, Home Recently-added/Rediscover rows
on cold start — showed only the Lucide.Disc3 placeholder.
Same placeholder-URL trick as TrackRef.coverUrl:
- models/AlbumRef.kt — adds `displayCoverUrl` computed property
that returns the server-given coverUrl when populated, falls
back to `http://placeholder.invalid/api/albums/{id}/cover`
otherwise. BaseUrlInterceptor rewrites the host; Coil's shared
OkHttp picks up the auth cookie. The original `coverUrl` field
is preserved so callers that need to distinguish
"server-provided" from "derived" can.
- library/widgets/AlbumCard.kt — switches the branch from
`album.coverUrl.isEmpty()` to `album.id.isEmpty()`. The cover
Box gets a surfaceVariant background so failed image loads
(e.g. album server-side without art) degrade to a tinted
square rather than transparent. A proper error-slot fallback
icon is a future refinement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Factors the cover-thumbnail composable out of LikedTab into a shared
widget so PlaylistDetail tracks and HistoryTab rows can reuse it.
Same placeholder-URL trick — BaseUrlInterceptor rewrites the host
on every request and Coil shares the OkHttp client.
New:
- shared/widgets/TrackCoverThumb.kt — composable with size +
coverUrl + contentDescription params. Defaults to 48dp; passes
the size through to both the clip and the fallback icon so
callers can scale up/down without re-implementing.
Modified:
- models/Playlist.kt — adds `coverUrl` derived prop on
PlaylistTrackRef. Same `/api/albums/{id}/cover` pattern as
TrackRef.coverUrl; empty when albumId is null (the
track-removed-from-library case).
- likes/ui/LikedTab.kt — drops the local TrackCoverThumb copy,
uses the shared one. Removes 8 now-unused imports.
- playlists/ui/PlaylistDetailScreen.kt — adds cover thumb to track
rows. Drops the leading position number (1, 2, 3...) since row
order already conveys position and the cover fills that visual
slot.
- history/ui/HistoryTab.kt — adds cover thumb leading each
history row. Vertical padding tightened 10dp → 8dp to match the
other thumb-bearing rows.
Search track results stay deferred — its TextRow handles
artists/albums/tracks generically, splitting it for per-track
covers is a bigger change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Lucide.Music placeholder with real album art on the two
highest-visibility "track without cover" surfaces. Adds the Room v3
schema export that Phase 20's schema bump generated.
New:
- models/TrackRef.kt — adds a derived `coverUrl` extension that
points at `/api/albums/{albumId}/cover` via the
`http://placeholder.invalid` host. BaseUrlInterceptor rewrites
it to the live server URL on every request; Coil shares the
same OkHttp client as Retrofit, so the rewrite + auth-cookie
flow applies identically. Empty `albumId` yields an empty
string; callers branch to show the placeholder icon instead.
- app/schemas/.../AppDatabase/3.json — Room schema artifact from
the Phase 20 v2→v3 bump (themeMode column on auth_session).
Modified:
- home/ui/HomeScreen.kt — CompactTrackTile (Home Most-Played
section) renders AsyncImage when track.coverUrl is non-empty,
falls back to the existing Lucide.Music icon when blank.
Background tinted with surfaceVariant so the placeholder reads
as an empty cover slot.
- likes/ui/LikedTab.kt — LikedTrackRow restructured from Column to
Row with a 48dp TrackCoverThumb leading the title/artist column.
Same AsyncImage-with-fallback pattern.
Album/Playlist/Search/History track rows defer for now — those are
dense and the 56dp cover would push row heights significantly.
Want to see the cover-on-tracks pattern on the simpler screens
first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shuffle button on AlbumDetail was wired to the same code path
as Play — both called `play(startTrackId = null)` which started the
queue in track order. PlaylistDetail already does inline `.shuffled()`
for shuffle; mirror that on AlbumDetail via a new `shuffle()` VM
method that pre-shuffles the track list before handing it to the
player.
A future refinement could use Media3's `setShuffleModeEnabled` for
play-then-shuffle without disturbing the original queue ordering,
but pre-shuffling matches the existing PlaylistDetail behavior and
keeps both screens consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed out they were being forced to clear "http://localhost:8080"
before typing their actual server URL. That default came from
AuthStore.DEFAULT_BASE_URL — an internal HTTP-client fallback for
when nothing's been configured, never something a user typed.
Now: pre-fill only when the stored URL is something the user
actually saved (anything other than the default). Otherwise leave
the field empty so the placeholder ("https://minstrel.example.com")
shows through and they can just start typing.
Edit case still works: if a user already saved e.g.
"http://192.168.1.10:8080" and re-opens ServerUrl after sign-out,
the field is pre-filled with that real value for them to edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit (45e2248) committed only the two new theme files; the
six modified files (MainActivity, AuthStore, AppDatabase, AuthSessionDao,
AuthSessionEntity, SettingsScreen) silently didn't get staged. This
commit lands the actual integration so the theme picker works end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-controllable theme override. Persists across cold restart;
default is SYSTEM (follows the device setting via isSystemInDarkTheme).
Schema bump: AppDatabase v2→v3 to add themeMode column on
auth_session. fallbackToDestructiveMigration is still in place so
the upgrade wipes local cache + cookie + user JSON on first launch
after update — destructive but acceptable pre-v1, since the sync
controller refills the cache from the server on next sign-in.
New:
- theme/ThemeMode.kt — SYSTEM / LIGHT / DARK enum with wire
(string) + toDarkOverride() (Boolean?) conversions.
Stored as the wire string; null persisted = SYSTEM.
- theme/ThemePreferenceViewModel.kt — surfaces AuthStore.themeMode
as a typed StateFlow + setter. Lives in the theme package so
MainActivity and SettingsScreen can both share it.
Modified:
- cache/db/entities/AuthSessionEntity.kt — adds themeMode column.
Comment updated to call out that the auth_session table is the
de-facto app-prefs row at this point, not strictly auth-only.
- cache/db/AppDatabase.kt — version 2 → 3.
- cache/db/dao/AuthSessionDao.kt — adds setThemeMode partial-update.
- auth/AuthStore.kt — adds themeMode StateFlow + setter +
persistThemeMode following the existing per-field pattern.
- MainActivity.kt — moves MinstrelTheme wrap from setContent into
the App() composable so it can read the theme preference.
BootSplash also wrapped in Surface(background) so the boot
flash uses the right background color.
- settings/ui/SettingsScreen.kt — Appearance ElevatedCard between
Account and About with a SingleChoiceSegmentedButtonRow of the
three options. Picks fire ThemePreferenceViewModel.setThemeMode
and the whole tree recomposes against the new MinstrelTheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit (0b72827) accidentally swept the local
android/.gradle, android/build, android/app/build, and
android/local.properties into the index via `git add android/`.
Root .gitignore only had entries for flutter_client/android/
paths, not the new native android/ tree.
Removes the cached files via `git rm --cached` and extends
.gitignore to cover the native Android Studio output dirs
(.gradle, .kotlin, .idea, build, app/build, local.properties,
*.iml). Source code is unaffected — only build-output and IDE
artifacts get untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions surfaced on the first real device run.
1. Contrast on ServerUrl + Login screens: both wrapped content in a
bare Box(fillMaxSize), no Surface. The obsidian background never
painted (rendered against the system root view's default),
LocalContentColor cascade fell through to Material's default
contentColor — the screens rendered as near-invisible dark text
on dark grey. Wrap both in Surface(color = background, contentColor
= onBackground) so the bg paints AND the M3 contentColor pipeline
flows correctly through OutlinedTextField labels / cursor /
placeholders + the Button content tint.
2. The bigger bug: NetworkModule.provideRetrofit read
authStore.baseUrl.value ONCE at Retrofit creation. AuthStore loads
from Room async, so at injection time the value was still the
localhost:8080 placeholder. Result: even after the user typed
their real server URL on the ServerUrl screen, every API call
kept hitting localhost:8080 ("Failed to connect to
localhost/127.0.0.1:8080" on the login attempt). The pre-fix
NetworkModule comment even acknowledged it — *"Server-URL
changes require an app relaunch"*.
Fix: per-request rewrite. New BaseUrlInterceptor reads the live
AuthStore.baseUrl.value on every request and rewrites
scheme/host/port of the outgoing URL. Retrofit now keeps a
placeholder baseUrl ("http://placeholder.invalid/") solely to
satisfy its parser; the actual target host is dynamic. Order in
OkHttp chain: BaseUrl first → Auth → logging, so the cookie
interceptor sees the final URL.
New:
- api/BaseUrlInterceptor.kt — per-request scheme/host/port rewrite
from AuthStore.baseUrl. Falls through to the original request
when the stored URL is unparseable.
Modified:
- api/NetworkModule.kt — adds BaseUrlInterceptor to the OkHttp
chain. Drops the AuthStore dependency from provideRetrofit;
swaps baseUrl for the placeholder.
- auth/ui/ServerUrlScreen.kt — Box → Surface wrap.
- auth/ui/LoginScreen.kt — Box → Surface wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as LikesRepository.toggleLike and friends. The catch
returns false → the row stays in the queue; that's the whole point
of the replayer. Added a comment explaining future diagnostic
logging plans.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last MVP infrastructure gap. Queued like-toggles,
Lidarr requests, and quarantine-unflags now actually reach the
server instead of accumulating in cached_mutations forever.
New:
- cache/mutations/MutationReplayer.kt — @Singleton. On
construction subscribes to AuthStore.sessionCookie and runs a
drain pass on every signed-in transition (cold start with
persisted cookie OR fresh sign-in). Reads pending rows in
FIFO order via CachedMutationDao.getAll, dispatches each by
kind to the raw Retrofit API:
LIKE_TOGGLE → LikesApi.like / unlike
REQUEST_CREATE → DiscoverApi.createRequest
QUARANTINE_UNFLAG → QuarantineApi.unflag
Crucially, uses the raw API interfaces — going through the
Repository wrappers would re-enqueue on failure, creating an
infinite-loop. Successful rows are deleted; failed rows stay in
place with attempts + lastAttemptAt updated. Unknown kinds are
dropped (claim success) so a stale schema entry can't wedge the
queue. Single in-flight via Mutex so back-to-back cookie events
coalesce.
Modified:
- MinstrelApplication.kt — adds @Inject lateinit var
mutationReplayer (same construct-the-singleton trick used for
ResumeController and SyncController). Without the @Inject Hilt
never instantiates the replayer and its init {} cookie observer
never subscribes.
Closes Phase 18 + every known MVP infrastructure gap. Remaining
known follow-ups (NOT MVP blockers):
- WorkManager-driven connectivity-listener replayer so queued
writes drain even with the app backgrounded. Current trigger
set (app open + sign-in) covers the common path.
- Exponential backoff + max-attempts cap so permanently-failing
rows eventually fail visibly rather than silently retrying
forever. Retry-forever is cheap given small queue sizes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trailing-underscore name tripped detekt's `(_)?[a-z][A-Za-z0-9]*`
pattern. `internal` matches the naming convention every other VM
in the codebase uses for the private MutableStateFlow shadowed by
a public StateFlow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last known MVP gap: after a fresh app launch with a
persisted session cookie, the Settings screen now shows the actual
username instead of going blank until the next sign-in.
Schema bump: AppDatabase version 1 → 2 to add userJson column on
auth_session. fallbackToDestructiveMigration already in
DatabaseModule handles the upgrade — users lose the cookie + cached
content on first launch after update, the sync controller refills,
and the next sign-in repopulates the user row. Acceptable pre-v1.
New / Modified:
- cache/db/entities/AuthSessionEntity.kt — adds `userJson: String?`.
- cache/db/AppDatabase.kt — version 1 → 2.
- cache/db/dao/AuthSessionDao.kt — adds setUserJson partial-update.
- auth/AuthStore.kt — `userJson: StateFlow<String?>` + setter +
persistUserJson. Refactored persist* methods to share a single
currentEntity() builder so adding the third field didn't triple
the boilerplate.
- models/UserRef.kt — @Serializable so AuthController can encode
it for storage.
- auth/AuthController.kt — injects ApplicationScope + Json. On init,
collects authStore.userJson and reflects decoded UserRef into
currentUser. signIn() now writes the user JSON through to
AuthStore; signOut() clears it. Decode failures collapse to null
rather than crash (worst case: blank username until next
sign-in).
- settings/ui/SettingsViewModel.kt — combine() over
authStore.baseUrl + authController.currentUser + a local
transient state flow, so the username updates the moment the
rehydrated UserRef arrives rather than being a one-shot snapshot
at VM construction time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the per-track-likes gap on PlaylistDetail (Album + Artist
details already had it via Phase 14). Same VM-owned-state pattern:
LikesRepository observeLikedTracks → mutableSet<String> Flow,
toggleLikeTrack via the optimistic-write + MutationQueue path.
Modified:
- playlists/ui/PlaylistDetailScreen.kt — VM gets LikesRepository
injection + `likedTrackIds: StateFlow<Set<String>>` +
`toggleLikeTrack(trackId)`. PlaylistDetailBody threads the set
+ onToggleTrackLike down to each row. TrackRow renders LikeButton
only when the upstream track is still available (greyed-out
rows for removed tracks omit the heart entirely — can't like
something that no longer exists).
Row vertical padding tightened 10dp → 8dp to match the album
track-row sizing now that the heart icon is present.
Cross-restart user persistence is the next commit within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last ComingSoon stub in the nav graph. Queue route now
renders the live PlayerController queue with the active row
highlighted; tap a row to jump to that position via the new
seekToIndex transport method.
New:
- player/ui/QueueScreen.kt — Scaffold + back-button AppBar; reads
the same PlayerViewModel that powers MiniPlayer / NowPlaying so
queue state stays in sync across all three. Active row gets a
12% primary-tinted background + Volume2 leading icon so the user
sees where they are. Empty queue shows "Queue is empty" hint.
Modified:
- player/PlayerController.kt — adds `seekToIndex(index: Int)`:
bounds-checked jump to a queue position via
MediaController.seekTo(mediaItemIndex, 0L) + auto-play.
- player/ui/PlayerViewModel.kt — exposes seekToIndex pass-through.
- player/ui/NowPlayingScreen.kt — takes navController now; adds a
ListMusic icon button below the transport row that navigates to
Queue.
- nav/MinstrelNavGraph.kt — Queue route renders QueueScreen;
NowPlaying composable threads navController. Drops the
ComingSoon helper + its EmptyState import — every route now has
a real screen, no stub fallback needed.
Closes Phase 16. Every named v2026.05.21.0 route has a working
native screen now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DatabaseModule's whole job is to host one @Provides per DAO; the
12/11 trip is structural, not a smell. Adding a second module file
to split DAO providers arbitrarily by family would be busier work,
not cleaner. Suppress with a comment that explains why.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SyncController constructor injection failed — SyncMetadataDao
existed on AppDatabase but had no per-DAO bridge in DatabaseModule
(no consumer until SyncController landed). Same fix as the earlier
CachedHomeIndexDao + CachedLikeDao + CachedMutationDao pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Library-tabs-start-empty gap. Mirrors
`flutter_client/lib/cache/sync_controller.dart`, scoped to artist /
album / track only (likes refresh via /api/likes/ids; playlists pull
on screen visit). The full multi-entity sync is overkill for v1
native.
New:
- models/wire/SyncResponseWire.kt — SyncArtistWire +
SyncAlbumWire + SyncTrackWire (raw DB-row shape returned by
/api/library/sync; distinct from the regular API's display
shapes which include derived album_count / cover_url etc.).
Plus SyncUpsertsWire / SyncDeletesWire / SyncResponseWire
envelopes.
- api/endpoints/SyncApi.kt — Retrofit GET /api/library/sync.
Returns Response<...> so the controller can branch on 200 /
204 (no changes since cursor) / 410 (cursor too old, wipe +
retry) without HttpException catches.
- cache/sync/SyncController.kt — @Singleton. Self-starting: on
construction subscribes to AuthStore.sessionCookie and fires
syncSafe() whenever it transitions to a non-null value (fresh
sign-in OR cold start with persisted cookie). Applies upserts
via the existing upsertAll DAO methods, applies deletes via
deleteByIds. Cursor + lastSyncAt persisted in sync_metadata so
the next sync resumes from the new watermark.
On 410 (server compaction window exceeded), resets the cursor
to 0 and recurses; the next response carries the full entity
set, which upsertAll overwrites with. Stale rows for entities
the server no longer knows about linger until a later 410 or
app-data-clear — acceptable for v1.
Modified:
- MinstrelApplication.kt — adds @Inject lateinit var
syncController (same construct-the-singleton trick used for
ResumeController). Without the @Inject the Hilt graph would
never instantiate the controller and its init {} cookie
observer wouldn't subscribe.
Likes / playlists / playlist_tracks deltas from the same endpoint
are deferred. Their dedicated refresh paths already populate the
local cache; folding them into the sync flow is an opportunistic
optimization, not an MVP gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the silent gap where there was no UI to like anything.
Now the Liked tab can actually grow from in-app actions, not just
from the /api/likes/ids sync seed.
New:
- shared/widgets/LikeButton.kt — heart toggle composable.
Caller-owned state: (liked: Boolean, onToggle: () -> Unit).
Tinted with M3 primary slot on liked, onSurfaceVariant on
unliked — tracks the light/dark theme without per-mode branches.
Modified:
- library/ui/AlbumDetailViewModel.kt — injects LikesRepository;
exposes `albumLiked: StateFlow<Boolean>` (observeIsLiked for the
album) + `likedTrackIds: StateFlow<Set<String>>` (observeLikedTracks
mapped to a Set for O(1) row-level lookup). `toggleLikeAlbum()` +
`toggleLikeTrack(id)` route through the repo's optimistic-write +
MutationQueue path.
- library/ui/AlbumDetailScreen.kt — LikeButton on the header next
to the cover/title block, LikeButton on every track row after
the duration. Track row vertical padding tightened from 12dp →
8dp to give the heart breathing room.
- library/ui/ArtistDetailViewModel.kt — injects LikesRepository;
exposes `artistLiked: StateFlow<Boolean>` + `toggleLikeArtist()`.
- library/ui/ArtistDetailScreen.kt — LikeButton on the header
between the name column and Play button.
PlaylistDetail per-track likes follows in a small follow-up — same
pattern, just hadn't been integrated yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Search-route ComingSoon stub with the real
three-facet search. Mirrors `flutter_client/lib/search/search_screen.dart`.
New:
- models/wire/SearchWire.kt — three concrete PagedXxxWire types
(Artists / Albums / Tracks) wrapping the server's Page[T] envelope,
plus SearchResponseWire. Going non-generic on the paged wrapper is
fine — three call sites and the explicit types read clearer than
a generic with manual type-arg gymnastics at decode time.
- models/SearchResponseRef.kt — domain envelope with `isEmpty` for
the screen's "no matches" branch.
- api/endpoints/SearchApi.kt — Retrofit GET /api/search with q +
limit + offset.
- search/data/SearchRepository.kt — trivial wire→domain mapper;
debouncing intentionally lives in the VM.
- search/ui/SearchViewModel.kt — 250ms debounce on the query Flow
via debounce() + distinctUntilChanged(). Empty query collapses
to Idle without firing a request. `playTrack(track)` queues a
single track via PlayerController for the same single-row-play
pattern as HistoryTab.
- search/ui/SearchScreen.kt — auto-focus the field on entry (the
user typed Search to start typing), inline X button to clear.
Results render as three sections (Artists / Albums / Tracks),
each section omitted when its list is empty. Artist taps →
ArtistDetail, album taps → AlbumDetail, track taps → play.
Modified:
- nav/MinstrelNavGraph.kt — Search route renders the real screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ArtistDetail-route ComingSoon stub with the real artist
view. Mirrors `flutter_client/lib/library/artist_detail_screen.dart`.
New:
- models/ArtistDetailRef.kt — ArtistRef + albums pair.
- library/ui/ArtistDetailViewModel.kt — VM + UiState. `playArtist()`
fires GET /api/artists/{id}/tracks and dumps the whole list into
the player queue (`source = "artist:$id"`). Errors are silent for
now — the play button has no inline error affordance.
- library/ui/ArtistDetailScreen.kt — Scaffold + back-button AppBar.
Header is a full-width LazyVerticalGrid spanning row with
circular avatar + name + album count + Play button; below it,
the albums tile out as an adaptive 176dp grid using the existing
AlbumCard.
Modified:
- library/data/LibraryRepository.kt — refreshArtistDetail now
returns ArtistDetailRef (mirroring AlbumDetail). Adds
fetchArtistTracks() for the Play button.
- nav/MinstrelNavGraph.kt — ArtistDetail route renders the real
screen; drops the now-unused androidx.navigation.toRoute import
(all detail routes use SavedStateHandle.toRoute via the VM now).
Closes Phase 12. Like buttons on detail headers + per-track + shuffle
mode on the player are open follow-ups but don't block MVP nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the AlbumDetail-route ComingSoon stub with the real album
view. Mirrors `flutter_client/lib/library/album_detail_screen.dart`.
New:
- models/AlbumDetailRef.kt — AlbumRef + tracks pair returned by
refreshAlbumDetail.
- library/ui/AlbumDetailViewModel.kt — VM + UiState. Pulls album
detail on init via LibraryRepository.refreshAlbumDetail (now
returning AlbumDetailRef directly so we can render from the wire
response without waiting for Room emission). `play(startTrackId)`
builds the player queue with `source = "album:$id"` so the
server's rotation reporter can advance it.
- library/ui/AlbumDetailScreen.kt — Scaffold + TopAppBar with back
button; cover + title + artist + Play/Shuffle action row; LazyColumn
of TrackRow tiles (#, title, artist, duration; tap plays from
that position). Shuffle currently fires the same play path —
player-level shuffle is a follow-up once PlayerController.setQueue
grows a shuffle flag.
Modified:
- library/data/LibraryRepository.kt — refreshAlbumDetail now
returns AlbumDetailRef. Existing callers (the LibraryRepositoryTest)
ignore the return value, no breakage.
- nav/MinstrelNavGraph.kt — AlbumDetail route renders the real
screen; id flows via SavedStateHandle.toRoute() inside the VM,
so the composable block is a one-liner.
ArtistDetail follows in the next commit within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the auth loop. Settings shows the signed-in username and
server URL, and the sign-out button drops the cookie + clears
currentUser + best-effort POSTs /api/auth/logout.
New:
- settings/ui/SettingsViewModel.kt — VM + SettingsState (server URL,
username, signing-out spinner, signed-out latch).
- settings/ui/SettingsScreen.kt — three cards: Account (signed-in
username + server URL), About (Minstrel + version from
BuildConfig.VERSION_NAME + VERSION_CODE), Sign out (error-tinted
button that spins while in-flight). On signed-out, navigates to
ServerUrl with `popUpTo(0) { inclusive = true }` so the back
stack is empty.
Modified:
- nav/MinstrelNavGraph.kt — Settings route renders the real screen.
Closes Phase 11 modulo cross-restart user-identity persistence —
the cookie survives but `currentUser` is in-memory only, so a fresh
launch with an existing cookie leaves the username blank until the
next sign-in. That's the only known gap; queued as a small
AuthSessionEntity schema add when it surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold launch now lands on the right screen based on persisted auth
state instead of always starting at Home.
New:
- auth/ui/AuthGateViewModel.kt — one-shot StateFlow that resolves
the initial NavHost destination from AuthSessionDao directly
(not AuthStore.sessionCookie, which is null until Room's first
async emission). Three outcomes:
no row at all → ServerUrl
row with default URL and no cookie → ServerUrl
row with cookie absent / empty → Login
row with cookie present → Home
- auth/ui/ServerUrlScreen.kt — VM + Screen in one file (still under
the function-count cap). Validates `http(s)://` prefix +
non-empty, trims trailing slash before persisting. On success
navigates to Login and pops ServerUrl off the back stack.
Modified:
- MainActivity.kt — wraps the NavGraph in an App() composable that
reads the AuthGateViewModel. Renders a centered spinner
(BootSplash) while the gate resolves; once resolved, hands the
decision to MinstrelNavGraph as `startDestination`.
- nav/MinstrelNavGraph.kt — startDestination is now a parameter
(was hardcoded to Home). ServerUrl route renders the real screen
instead of ComingSoon.
Settings (with sign-out) is Commit C; cross-restart user identity
persistence + auth-error 401 redirect handling are later refinements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoginScreen body was 76 lines vs the 60 cap. Pulled the centered
Column into LoginForm() and the spin-aware Button into SubmitButton().
LoginScreen is back to ~20 lines (LaunchedEffect + Box around LoginForm).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation of the auth flow. POST /api/auth/login lands the user with
their session cookie captured by the existing AuthCookieInterceptor;
LoginScreen drives it, AuthController owns the in-memory currentUser.
New:
- models/wire/AuthWire.kt — LoginRequestBody + LoginResponseWire +
UserWire. The response `token` is duplicated for header-auth
clients; we use the Set-Cookie capture path instead.
- models/UserRef.kt — caller-facing identity (no password hash, no
api token, no Subsonic password). Matches the server's UserView
envelope.
- api/endpoints/AuthApi.kt — Retrofit POST /api/auth/login +
/api/auth/logout.
- auth/AuthController.kt — @Singleton facade. `currentUser`
StateFlow + `isSignedIn` (read straight off AuthStore.sessionCookie).
`signIn(username, password)` posts the login, surfaces the typed
UserRef. `signOut()` clears the cookie locally and best-effort
POSTs /logout (swallowed network failure — local state already
cleared, the server session times out on its own).
Cross-restart caveat: cookie persists in AuthStore but currentUser
is in-memory only — UI stays signed in across restarts but can't
render the username until a follow-up sign-in. Persisting user
JSON on AuthSessionEntity is a follow-up if it matters.
- auth/ui/LoginViewModel.kt — VM + LoginFormState (username +
password fields + submitting + error + signedIn flag). `submit()`
is a no-op while in-flight or with empty fields.
- auth/ui/LoginScreen.kt — centered form with two OutlinedTextFields
+ a Sign-in button that spins while in-flight. Error message
surfaces under the password field. On `signedIn = true` flips,
navigates to Home and pops Login off the back stack.
Modified:
- nav/MinstrelNavGraph.kt — Login route renders the real screen;
outsideShell extension now takes navController so it can be
threaded down (LoginScreen needs it for the post-sign-in nav).
ServerUrl screen + Settings + auth-gated redirect logic come in
Commits B / C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AdminUsersScreen body was 65 lines vs the 60 cap because both
dialogs were rendered inline. Pulled them into a single
PendingDialogs helper that takes the two nullable selections + the
clear/confirm callbacks. AdminUsersScreen is back to ~45 lines and
PendingDialogs is ~30.
File function count is still 8 (Screen + UserList + UserRow +
ToggleRow + PendingDialogs + ResetPasswordDialog + DeleteConfirmDialog
+ LoadingCentered), under the 11 cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the final two admin slices, closing Phase 10. Mirrors
`flutter_client/lib/admin/admin_users_screen.dart`,
`admin_landing_screen.dart`, and the `AdminUsersController` +
`adminCountsProvider` pieces of `admin_providers.dart`.
New:
- models/wire/AdminUserWire.kt — AdminUserWire + AdminUsersListWire
(server wraps the list in `{"users": [...]}`).
- models/AdminUserRef.kt — domain.
- api/endpoints/AdminUsersApi.kt — Retrofit list + setAdmin +
setAutoApprove + resetPassword + delete. PUT-auto-approve body
field is `auto_approve` (NOT `auto_approve_requests`) — matches
`internal/api/admin_users.go adminAutoApproveReq` and Flutter's
same-name workaround.
- admin/data/AdminUsersRepository.kt — list + four mutation methods.
- admin/ui/AdminUsersViewModel.kt — VM + UiState. Optimistic patch()
helper for the toggles (last-admin-guard rollback via refresh).
Delete is optimistic-remove with same rollback path. resetPassword
is fire-and-forget — server has no rollback for this anyway.
- admin/ui/AdminUsersScreen.kt — list of rows, each with two toggle
rows (Admin / Auto-approve) and Reset-password + Delete affordances.
Both are dialog-gated (typed-new-password / typed-confirm) so a
tap can't blow up an account by accident.
- admin/ui/AdminLandingScreen.kt — VM + counts + Screen. Fans out
to all three admin repositories in parallel via async/await inside
a coroutineScope; surfaces counts in three ElevatedCard tiles
(Requests / Quarantine / Users) that navigate to the slice screens.
Modified:
- nav/MinstrelNavGraph.kt — Admin + AdminUsers routes now render
their real screens; AdminLanding ties the slice together.
isAdmin gating on the kebab still pending Phase 11 AuthController.
Until then the admin entries are reachable from any account, fine
on a single-user dev install but gates before any release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the AdminQuarantine-route ComingSoon stub with the real
admin-side queue. Mirrors `flutter_client/lib/admin/admin_quarantine_screen.dart`
+ `AdminQuarantineController`.
New:
- models/wire/AdminQuarantineWire.kt — AdminQuarantineReportWire +
AdminQuarantineItemWire (per-user report and the aggregated row
that collapses reports into per-reason counts).
- models/AdminQuarantineItem.kt — domain refs.
`topReasonSummary` returns the most-cited reason with a "(+N more)"
suffix when other distinct reasons exist.
- api/endpoints/AdminQuarantineApi.kt — Retrofit list + the three
resolution endpoints (resolve / delete-file / delete-via-lidarr).
- admin/data/AdminQuarantineRepository.kt — list + three actions
with internal mappers; same point-and-shoot pattern as
AdminRequestsRepository (no offline queue).
- admin/ui/AdminQuarantineViewModel.kt — VM + UiState in sibling
file. `act()` collapses the three resolution paths into one
optimistic-remove-then-refetch-on-failure helper.
- admin/ui/AdminQuarantineScreen.kt — Scaffold + TopAppBar with
back button + MainAppBarActions. Each row shows track title +
"artist · album" subtitle + report-count pill + top-reason pill +
three action buttons (Resolve / Delete file / Delete + Lidarr).
Modified:
- nav/MinstrelNavGraph.kt — AdminQuarantine route now renders
`AdminQuarantineScreen(navController = navController)` inside
ShellScaffold.
AdminUsers + AdminLanding + Invites still ComingSoon — D3 lands them
together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the AdminRequests-route ComingSoon stub with the real
admin-side review queue. Mirrors `flutter_client/lib/admin/admin_requests_screen.dart`
+ `AdminRequestsController` from `admin_providers.dart`.
New:
- api/endpoints/AdminRequestsApi.kt — Retrofit GET
/api/admin/requests + POST {id}/approve + POST {id}/reject.
Reuses RequestWire (server returns the same requestView shape as
/api/requests; only the listing scope differs).
- admin/data/AdminRequestsRepository.kt — list + approve + reject
+ internal wire→domain mapper. No MutationQueue fallback —
operator confirmation is point-and-shoot; failures roll back via
refetch rather than queuing for later replay.
- admin/ui/AdminRequestsViewModel.kt — VM + UiState (in its own
file to preempt TooManyFunctions). `decide()` optimistically
drops the row from local state and refetches on failure so the
UI matches the server's view.
- admin/ui/AdminRequestsScreen.kt — Scaffold + TopAppBar with back
button + MainAppBarActions; LazyColumn of rows showing displayName
+ kind/status pills + Approve/Reject button pair.
Modified:
- nav/MinstrelNavGraph.kt — AdminRequests route now renders
`AdminRequestsScreen(navController = navController)` inside
ShellScaffold.
AdminQuarantine + AdminUsers + AdminLanding still ComingSoon — they
follow in D2/D3. No isAdmin gating on the kebab yet either; until
the AuthController lands (Phase 11), the admin entries are reachable
from any account, which is fine on a single-user dev install but
gets gated before any release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- models/Quarantine.kt → QuarantineRef.kt
- models/wire/QuarantineWire.kt → QuarantineMineWire.kt
Same fix as the earlier LikesWire → LikedIdsWire rename — detekt's
MatchingDeclarationName wants files with a single top-level
declaration to share its name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library Hidden-tab ComingSoonTab placeholder with the
real screen. Mirrors `flutter_client/lib/library/library_screen.dart`
`_HiddenTab` / `_QuarantineTile`.
New:
- models/wire/QuarantineWire.kt — @Serializable QuarantineMineWire
matching `/api/quarantine/mine`.
- models/Quarantine.kt — QuarantineRef domain; `reasonLabel` maps
the raw reason key ("bad_rip" / "wrong_file" / "wrong_tags" /
"duplicate" / other) to its display string.
- api/endpoints/QuarantineApi.kt — Retrofit listMine + flag + unflag
plus a FlagRequest @Serializable body for POST. The unflag
endpoint is idempotent server-side, which is what makes the
queued-replay path safe.
- quarantine/data/QuarantineRepository.kt — listMine + offline-first
unflag that returns UnflagOutcome (ACCEPTED vs. QUEUED).
`feedback_offline_first_for_server_writes` rationale identical
to LikesRepository.toggleLike.
- quarantine/ui/HiddenTabViewModel.kt — VM + UiState in its own
file (preempts TooManyFunctions). Optimistic-remove on unflag
with no rollback; the queue replays on failure.
- quarantine/ui/HiddenTab.kt — composable. List of rows showing
track title + "artist · album" subtitle + reason pill + Unhide
icon button (Lucide.ArchiveRestore).
Modified:
- cache/mutations/MutationQueue.kt — adds MutationKind.QUARANTINE_UNFLAG
+ QuarantineUnflagPayload + enqueueQuarantineUnflag helper.
- library/ui/LibraryScreen.kt — TAB_HIDDEN dispatch swaps from
ComingSoonTab to `HiddenTab()`. Drops the now-unreferenced
ComingSoonTab helper.
Admin slices (admin requests / quarantine / users) are still Commit D.
Flag-from-track-row UI lands once Album/Artist/Playlist detail screens
expose per-track action menus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Requests-route ComingSoon stub with the real screen.
Mirrors `flutter_client/lib/requests/requests_screen.dart`.
New:
- models/wire/RequestWire.kt — @Serializable matching
`internal/api/requests.go requestView`. Used by both
/api/requests and /api/admin/requests (admin reuse comes in
Commit D).
- models/Request.kt — RequestRef domain + RequestStatus enum
(PENDING / APPROVED / REJECTED / COMPLETED / FAILED / UNKNOWN).
`displayName` picks the kind-appropriate field (albumTitle for
album-kind etc).
- api/endpoints/RequestsApi.kt — Retrofit: GET /api/requests + DELETE
/api/requests/{id}. The cancel response returns the cancelled
row (not 204), so the wire return type is RequestWire.
- requests/data/RequestsRepository.kt — listMine + cancel with
internal wire→domain mapper. No Room cache — same rationale as
HistoryRepository.
- requests/ui/RequestsViewModel.kt — VM + UiState in a sibling file
(preempts the TooManyFunctions cap that Discover hit).
`cancel(id)` is optimistic: drops the row from local state
immediately, refetches on success (or rolls back via refetch on
failure).
- requests/ui/RequestsScreen.kt — composable: title bar with back
button + MainAppBarActions, LazyColumn of RequestRow tiles
(display name + status/kind pills + Cancel button visible only
on PENDING). Rejected requests surface the server-provided notes
line.
Modified:
- nav/MinstrelNavGraph.kt — Requests route now renders
`RequestsScreen(navController = navController)` inside ShellScaffold.
Hidden tab (quarantine) + admin slices come in Commits C and D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverTiles.kt held only one non-composable top-level decl
(`enum class AvatarShape`), so detekt wanted the file renamed. The
enum had two variants discriminating circle vs. rounded; a Boolean
`circular` arg is the same information with no extra cost — collapses
the `when` into a one-line `if`. Two call sites updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverScreen.kt still tripped TooManyFunctions (13/11) after the VM
split because the file kept all the per-row composables + helpers.
Moved SuggestionTile + ResultTile + Avatar + AvatarShape into a
sibling DiscoverTiles.kt and dropped the now-unused imports
(background, CircleShape, RoundedCornerShape, AssistChip, Disc3,
User, AsyncImage, size, clip, ImageVector, TextOverflow).
DiscoverScreen.kt now hosts 10 functions: the screen + 6 layout-only
helpers (SearchBar, KindChips, SuggestionsPane, SuggestionsList,
SuggestionsHeader, ResultsList) + LoadingCentered + CenteredMessage
+ snackbarFor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DiscoverScreen.kt hit detekt's TooManyFunctions limit (11) at 13.
Moved DiscoverState / SuggestionState / ResultsState / DiscoverViewModel
into DiscoverViewModel.kt — same package, same imports, no behavior
change. Screen file now hosts only composables + the snackbarFor helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Discover surface — Lidarr search bar over an out-of-library
artist suggestion feed, with offline-first request-creation through
the MutationQueue. Mirrors `flutter_client/lib/discover/discover_screen.dart`.
New:
- models/wire/DiscoverWire.kt — LidarrSearchResultWire,
ArtistSuggestionWire (with SeedContribution children), and the
CreateRequestBody POST shape.
- models/Discover.kt — domain refs + LidarrRequestKind enum.
ArtistSuggestionRef.attributionText preserves the Oxford-comma,
"Because you liked X / played Y" phrasing from Flutter.
- api/endpoints/DiscoverApi.kt — Retrofit: GET /api/discover/suggestions,
GET /api/lidarr/search (q + kind), POST /api/requests.
- discover/data/DiscoverRepository.kt — read-through search +
suggestion + offline-first createRequest. Returns
RequestOutcome.ACCEPTED on 2xx, RequestOutcome.QUEUED when the
call got buffered into the MutationQueue. Same swallowed-exception
rationale as LikesRepository.toggleLike.
- discover/ui/DiscoverScreen.kt — VM + state + composable. Filter
chips for Artists / Albums kind, search field with ImeAction.Search
submit, results list vs. suggestion feed swap based on whether
the query box is empty. Snackbar wording switches between
"Requested: X" and "Request queued: X" based on outcome.
Locally-just-requested MBIDs are tracked so the "Request" affordance
swaps to a "Requested" pill instantly without waiting for a server
refetch.
Modified:
- cache/mutations/MutationQueue.kt — adds MutationKind.REQUEST_CREATE
+ RequestCreatePayload + enqueueRequestCreate helper.
- nav/MinstrelNavGraph.kt — Discover route swaps from ComingSoon to
`DiscoverScreen(navController = navController)`.
Requests screen (your-own requests list with status/cancel) and the
admin/quarantine slices land in follow-up commits within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folded the early-return ladder in `relativeTime` into a single `when`
returning the result. Same four cutoff windows — `<1h` / `<24h` /
`<7d` / older — just bound to a single expression so detekt's
ReturnCount (limit 2) is satisfied. The opening "unparseable → return
raw" still uses an early return; that's only 2 total now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library History-tab ComingSoon placeholder with a real
listening-history view. Tapping a row plays that single track via
PlayerController, matching Flutter's `_HistoryTab` behavior.
New:
- models/wire/HistoryWire.kt — @Serializable HistoryEventWire +
HistoryPageWire (`{events, has_more}`, the non-`Page<T>` envelope
/api/me/history actually returns).
- api/endpoints/HistoryApi.kt — Retrofit `GET /api/me/history` with
limit/offset query params (50/0 default).
- history/data/HistoryRepository.kt — fetch-only (no Room cache for
v1; the offline snapshot mirror lands with Phase 13). Maps the
wire shape into a UI-friendly HistoryEntry that carries the
converted TrackRef so playback wiring stays consistent with the
other tabs.
- history/ui/HistoryTab.kt — VM + UiState + composable. List of
HistoryRow tiles (title + artist + relative-time stamp);
tap-to-play single track via PlayerController.setQueue. The
`relativeTime` formatter mirrors Flutter's `library_screen.dart`
`_relativeTime` four-window strategy:
< 1h → "Nm ago"
< 24h → "Nh ago"
< 7d → "Tue 14:32"
≥ 7d → "May 1" (or "May 1, 2025" cross-year)
Built on `java.time.OffsetDateTime` — fine on minSdk 26 without
core-library desugaring.
Modified:
- library/ui/LibraryScreen.kt — TAB_HISTORY dispatch swaps from
ComingSoonTab to `HistoryTab()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LikesRepository.kt: the catch around best-effort like REST is
intentional swallow (the queue replays later). Added
`@Suppress("SwallowedException")` alongside the existing
`TooGenericExceptionCaught`, plus a comment explaining the choice
so the next reader doesn't "fix" it.
- models/wire/LikesWire.kt → LikedIdsWire.kt: file held a single
top-level declaration (LikedIdsWire); detekt's
MatchingDeclarationName rule wants the filename to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Library Liked-tab ComingSoon placeholder with a real
hydrated view of liked artists / albums / tracks, plus the
offline-first toggle-like write path.
New:
- cache/mutations/MutationQueue.kt — minimal write-side of the
offline mutation queue (Phase 8 v1 only enqueues; the
connectivity-aware drain lands with Phase 12.2 SyncController).
Defines MutationKind.LIKE_TOGGLE + LikeTogglePayload + an
enqueueLikeToggle helper.
- api/endpoints/LikesApi.kt — Retrofit (POST/DELETE
`api/likes/{kind}/{id}` + GET `/api/likes/ids`). Plural-segment
mapping ("artist" → "artists") lives in LikesRepository.
- models/wire/LikesWire.kt — @Serializable LikedIdsWire matching
`internal/api/likes.go likedIDsResponse`.
- likes/data/LikesRepository.kt — observe (hydrated artists /
albums / tracks via the existing CachedAlbumDao /
CachedArtistDao / CachedTrackDao) + observeIsLiked +
toggleLike + refreshIds. Local userId is hardcoded as "local"
behind LOCAL_USER_ID; TODO marker for the Phase-11 swap to
AuthStore.userId. Write path is optimistic Room mutation →
best-effort REST → enqueue-on-failure, per
`feedback_offline_first_for_server_writes`.
- likes/ui/LikedTab.kt — VM + UiState + composable. Three sections
(Artists horizontal row, Albums horizontal row, Tracks list) +
"No likes yet" empty state. Tile taps navigate to detail screens;
track-row taps are disabled until Phase 9 wires history play-from
-here (placeholder so the row reads correctly without an
unfinished playback affordance).
Modified:
- cache/db/DatabaseModule.kt — @Provides for CachedLikeDao +
CachedMutationDao (DAOs existed but had no consumer until now).
- library/ui/LibraryScreen.kt — TAB_LIKED dispatch swaps from
ComingSoonTab to `LikedTab(navController)`.
Like buttons on detail screens come with the AlbumDetail / ArtistDetail
real-screen build, which is a separate later commit (they're still
ComingSoon stubs in MinstrelNavGraph).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaylistDetailScreen takes navController for its TopAppBar back-button,
but the inShellDetail NavGraphBuilder extension was still scoped to
just expandPlayer. Added navController as the first parameter and
updated the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>