Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.7 2851f8c694 chore(android): untrack local Gradle/build cache, extend .gitignore
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>
2026-05-26 00:35:56 -04:00
bvandeusenandClaude Opus 4.7 0b72827682 fix(android): unblock first device test — auth contrast + dynamic base URL
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>
2026-05-26 00:27:05 -04:00
bvandeusenandClaude Opus 4.7 0415b5ccc3 fix(android): suppress SwallowedException on the 3 dispatch catches
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>
2026-05-25 23:32:52 -04:00
bvandeusenandClaude Opus 4.7 2f205eb0d9 feat(android): Phase 18 — MutationReplayer drains offline write queue
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>
2026-05-25 23:11:49 -04:00
bvandeusenandClaude Opus 4.7 a87ec770a5 fix(android): rename state_ → internal (detekt VariableNaming)
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>
2026-05-25 22:44:44 -04:00
bvandeusenandClaude Opus 4.7 5869ec9505 feat(android): Phase 17b — persist user identity across cold restart
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>
2026-05-25 22:40:09 -04:00
bvandeusenandClaude Opus 4.7 16b3a1e9e2 feat(android): Phase 17a — PlaylistDetail per-track LikeButton
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>
2026-05-25 22:31:06 -04:00
bvandeusenandClaude Opus 4.7 f0278ed2bf feat(android): Phase 16 — Queue screen + View-queue affordance on NowPlaying
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>
2026-05-25 21:30:06 -04:00
bvandeusenandClaude Opus 4.7 b342a7c41b fix(android): suppress TooManyFunctions on DatabaseModule
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>
2026-05-25 21:12:38 -04:00
bvandeusenandClaude Opus 4.7 118f3d31f3 fix(android): add Hilt @Provides for SyncMetadataDao
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>
2026-05-25 20:57:21 -04:00
bvandeusenandClaude Opus 4.7 66dfc473db feat(android): Phase 15 — SyncController for /api/library/sync delta sync
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>
2026-05-25 19:39:22 -04:00
bvandeusenandClaude Opus 4.7 7dfe6f1b69 feat(android): Phase 14 — Like buttons on Album + Artist detail
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>
2026-05-25 18:46:59 -04:00
bvandeusenandClaude Opus 4.7 d96f835b5b feat(android): Phase 13 — Search screen + /api/search wiring
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>
2026-05-25 18:23:09 -04:00
bvandeusenandClaude Opus 4.7 de1175c478 feat(android): Phase 12 — ArtistDetail screen + closes phase
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>
2026-05-25 17:27:50 -04:00
bvandeusenandClaude Opus 4.7 72f36c0da8 feat(android): Phase 12 — AlbumDetail screen
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>
2026-05-25 12:54:49 -04:00
bvandeusenandClaude Opus 4.7 cb4b411ab6 feat(android): Phase 11 Commit C — Settings screen + sign-out
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>
2026-05-25 12:42:22 -04:00
bvandeusenandClaude Opus 4.7 5697bfeedf feat(android): Phase 11 Commit B — ServerUrl entry + auth-gated startDestination
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>
2026-05-25 12:31:22 -04:00
bvandeusenandClaude Opus 4.7 df026d8e74 fix(android): split LoginScreen into LoginForm + SubmitButton (detekt LongMethod)
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>
2026-05-25 11:15:51 -04:00
bvandeusenandClaude Opus 4.7 d6b8c20a17 feat(android): Phase 11 Commit A — AuthController + Login screen
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>
2026-05-25 02:03:41 -04:00
bvandeusenandClaude Opus 4.7 d6301f5bdc fix(android): extract PendingDialogs from AdminUsersScreen (detekt LongMethod)
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>
2026-05-25 01:48:01 -04:00
bvandeusenandClaude Opus 4.7 2e8124d496 feat(android): Phase 10 Commit D3 — Admin Users + Admin landing
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>
2026-05-25 01:41:43 -04:00
bvandeusenandClaude Opus 4.7 9d17636a39 feat(android): Phase 10 Commit D2 — Admin Quarantine screen
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>
2026-05-25 01:32:16 -04:00
bvandeusenandClaude Opus 4.7 2a55e6a647 feat(android): Phase 10 Commit D1 — Admin Requests screen (approve/reject)
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>
2026-05-25 01:16:07 -04:00
bvandeusenandClaude Opus 4.7 5108db6ad7 fix(android): rename Quarantine files to match single decl (detekt)
- 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>
2026-05-25 01:06:51 -04:00
bvandeusenandClaude Opus 4.7 75bdb635d0 feat(android): Phase 10 Commit C — Hidden tab (quarantine list + unflag)
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>
2026-05-25 00:52:09 -04:00
bvandeusenandClaude Opus 4.7 2b4226ee6a feat(android): Phase 10 Commit B — Requests screen (your requests + cancel)
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>
2026-05-25 00:33:56 -04:00
bvandeusenandClaude Opus 4.7 43d436573c fix(android): drop AvatarShape enum (detekt MatchingDeclarationName)
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>
2026-05-25 00:25:29 -04:00
bvandeusenandClaude Opus 4.7 786a73dad7 fix(android): extract Discover tiles to DiscoverTiles.kt (detekt TooManyFunctions)
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>
2026-05-25 00:21:42 -04:00
bvandeusenandClaude Opus 4.7 f573512940 fix(android): split DiscoverScreen — VM into its own file (detekt TooManyFunctions)
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>
2026-05-25 00:16:38 -04:00
bvandeusenandClaude Opus 4.7 734fc16ee6 feat(android): Phase 10 — Discover screen (Lidarr search + suggestions + request create)
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>
2026-05-25 00:11:14 -04:00
bvandeusenandClaude Opus 4.7 d10f13c9b1 fix(android): collapse relativeTime returns to one branch (detekt ReturnCount)
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>
2026-05-24 23:56:11 -04:00
bvandeusenandClaude Opus 4.7 9d63030961 feat(android): Phase 9 — history tab + /api/me/history wiring
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>
2026-05-24 23:14:33 -04:00
bvandeusenandClaude Opus 4.7 c26c2bde10 fix(android): detekt — suppress SwallowedException + rename LikesWire
- 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>
2026-05-24 23:04:12 -04:00
bvandeusenandClaude Opus 4.7 d047d6e046 feat(android): Phase 8 — likes write path + mutation queue + Liked tab
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>
2026-05-24 22:39:58 -04:00
bvandeusenandClaude Opus 4.7 6727bed35e fix(android): thread navController into inShellDetail extension
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>
2026-05-24 22:28:36 -04:00
bvandeusenandClaude Opus 4.7 5ef4d3454c fix(android): close HomeViewModel class brace
Refactor of the combine chain dropped the closing `}` for the
HomeViewModel class, so the screen composable below ended up
nested inside the ViewModel and the file's brace count was off
by one. Cascaded into "Unresolved reference HomeScreen" downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:12:21 -04:00
bvandeusenandClaude Opus 4.7 f6057747ba feat(android): Phase 7 — playlist detail screen + Home playlists row
Second slice of Playlists feature parity. PlaylistDetail route now
renders the real screen instead of the ComingSoon stub; Home's
Playlists row now shows actual PlaylistCards instead of the
placeholder header.

New:
  - playlists/ui/PlaylistDetailScreen.kt — VM + UiState + Screen.
    Header (cover + name + description + track count + Play / Shuffle
    buttons) over a track list (numbered TrackRow with duration).
    Unavailable tracks (trackId == null because the upstream library
    row was removed) render at 0.4 alpha and don't accept taps, per
    Flutter's `isAvailable` convention. Tap on a track plays the
    playlist starting there via `PlayerController.setQueue(refs,
    initialIndex, source = "playlist:$id")`. Shuffle reuses the same
    path on a `shuffled()` copy — cheap for the page-sized list.

Modified:
  - home/ui/HomeScreen.kt — HomeViewModel now also takes
    PlaylistsRepository; calls refreshList() alongside refreshIndex()
    on init. HomeSections gets a `playlists: List<PlaylistRef>` field
    (factored into isAllEmpty). HomeSuccessContent shows a real
    PlaylistsRow when non-empty (replacing the "Lands in Phase 7"
    EmptySectionHeader). Tile tap navigates to PlaylistDetail.
    Combine arity capped at 5 by kotlinx.coroutines, so the screen
    splits into observeHomeSections() (the five HomeRepository flows)
    chained against the PlaylistsRepository flow — avoids untyped
    vararg-combine gymnastics across heterogeneous list types.
  - nav/MinstrelNavGraph.kt — PlaylistDetail route swaps from
    ComingSoon to `PlaylistDetailScreen(navController = navController)`.
    The route id flows in via SavedStateHandle.toRoute() inside the
    ViewModel rather than backStackEntry.toRoute(), so the composable
    block is back to a one-liner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:51:53 -04:00
bvandeusenandClaude Opus 4.7 b855009ea0 feat(android): Phase 7 — playlists list (data layer + screen + nav)
Foundation slice of Playlists feature parity with Flutter v2026.05.21.0.
Replaces the ComingSoon stub on the Playlists route with a real
two-section list screen (System playlists / Your playlists), cache-first
against `cached_playlists`.

New:
  - models/Playlist.kt — PlaylistRef + PlaylistTrackRef domain models.
    Mirrors `flutter_client/lib/models/playlist.dart` (id + name +
    isSystem + cover + ownerUsername etc.; PlaylistTrack carries the
    full per-row display fields since cached_playlist_tracks only holds
    ordered (playlistId, trackId, position) triples).
  - models/wire/PlaylistWire.kt — @Serializable wire types
    (PlaylistWire / PlaylistsListWire / PlaylistTrackWire /
    PlaylistDetailWire), matching `internal/api/playlists.go`.
  - api/endpoints/PlaylistsApi.kt — Retrofit interface (list + get).
    Read-only for now; create/append/refreshSystem land with the
    mutation-queue phase.
  - playlists/data/PlaylistsRepository.kt — observe (all / user /
    system) cache-first reads + refreshList / refreshDetail that
    upsert Room and return a hydrated PlaylistDetailRef. Internal
    entity↔wire↔domain mappers kept private.
  - playlists/widgets/PlaylistCard.kt — 176dp tile sized to match
    AlbumCard for visual consistency in the upcoming Home carousel.
    Subtitle shows the system-variant label ("For You" / "Discover" /
    "Songs like…" / etc.) or "N tracks" for user playlists.
  - playlists/ui/PlaylistsListScreen.kt — Scaffold + TopAppBar +
    MainAppBarActions; LazyVerticalGrid with adaptive 176dp cells.
    Renders both `System playlists` and `Your playlists` sections via
    full-width section headers (`GridItemSpan(maxLineSpan)`).

Modified:
  - cache/db/DatabaseModule.kt — adds @Provides for CachedPlaylistDao
    + CachedPlaylistTrackDao (DAOs existed on AppDatabase since slice 1
    but had no consumer until now).
  - nav/MinstrelNavGraph.kt — swaps the ComingSoon stub for
    `PlaylistsListScreen(navController = navController)`.

PlaylistDetail route still ComingSoon; lands in the next commit along
with Home-page Playlists row integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:26:26 -04:00
bvandeusenandClaude Opus 4.7 df9ae2db77 fix(android): name Library tab indices (detekt MagicNumber)
detekt flagged the `3` and `4` branch labels in the `when (selectedTab)`
block. Promoted all five indices to TAB_ARTISTS … TAB_HIDDEN constants
— same shape detekt would have suggested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:59:30 -04:00
bvandeusenandClaude Opus 4.7 6062233c63 feat(android): Library 5-tab restructure (sub-task 4)
Replaces the transitional two-LazyRow shape with the proper 5-tab
Library matching `flutter_client/lib/library/library_screen.dart`:

  [Artists] [Albums] [History] [Liked] [Hidden]

  - Artists / Albums — real adaptive 3+-up LazyVerticalGrid backed by
    the existing LibraryViewModel (cache-first reads of cached_artists
    / cached_albums). GridCells.Adaptive sizes from card width
    (ArtistCard 144dp, AlbumCard 176dp), so phones get 3 cols and
    tablets pack more.
  - History / Liked / Hidden — EmptyState placeholders with phase
    pointers (Phase 9 / 8 / 10 respectively). The data-layer plumbing
    for each lands with its umbrella phase, not as a Home/Library
    polish patch.

TabBar is `PrimaryScrollableTabRow` (Material3) so on narrower screens
the row scrolls horizontally without truncating labels — same
behavior as Flutter's `TabBar(isScrollable: true)`.

The Library tab is now reachable both as the in-shell route AND
through MainAppBarActions; the icon row suppresses the Library icon
when this screen is current (per `currentRouteName = Library::class.qualifiedName`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:41 -04:00
bvandeusenandClaude Opus 4.7 3123e4350e fix(android): add Hilt @Provides for CachedHomeIndexDao
HomeRepository's CachedHomeIndexDao constructor injection failed with
"cannot be provided without an @Provides-annotated method" — the DAO
existed on AppDatabase but the per-DAO bridge in DatabaseModule was
never added (no consumer until HomeRepository landed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:24:54 -04:00
bvandeusenandClaude Opus 4.7 9ab862a2e1 feat(android): real Home screen with /api/home/index sections (sub-task 3)
Replaces the ComingSoon stub with the actual Home matching the Flutter
client's `home_screen.dart` shape — vertical Column of named horizontal
sections, each row a labeled `HorizontalScrollRow`:

  Playlists (placeholder header — Phase 7)
  Recently added         → AlbumCards
  Rediscover             → AlbumCards (and/or ArtistCards)
  Most played            → CompactTrackTile
  Last played            → ArtistCards

Wire path: `GET /api/home/index` → `HomeRepository.refreshIndex()`
upserts five sections into `cached_home_index`. Per-section Flows
observe that table and hydrate each ID against the existing
album/artist/track DAOs — rows that haven't been pulled into Room yet
are silently dropped from emission (the per-tile hydration queue from
Phase 7+ will fill those gaps).

New files:
  - models/wire/HomeIndexWire.kt — @Serializable wire shape
  - api/endpoints/HomeApi.kt — Retrofit `GET /api/home/index`
  - home/data/HomeRepository.kt — observe/refresh + section constants
  - home/ui/HomeScreen.kt — composable + HomeViewModel + HomeUiState
    + inline CompactTrackTile (proper CompactTrackCard lands in Phase 7
    alongside player play-by-ID and per-track cover hydration)
  - shared/widgets/HorizontalScrollRow.kt — labeled LazyRow wrapper
    that takes a LazyListScope block (keeps row items lazy)

Modified:
  - nav/MinstrelNavGraph.kt — startDestination = Home (was Library;
    matches Flutter's `redirect '/' → '/home'`); Home composable now
    renders the real `HomeScreen(navController)` inside `ShellScaffold`

CompactTrackTile uses a Lucide.Music placeholder for the cover because
TrackRef doesn't carry coverUrl — matching the Flutter tile-provider
hydration is a Phase-7 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:18:48 -04:00
bvandeusenandClaude Opus 4.7 6c8694db2e fix(android): rename Lucide.MoreVertical → EllipsisVertical
Upstream Lucide renamed `more-vertical` → `ellipsis-vertical`; the
icons-lucide-cmp 2.2.1 bundle only exposes the new name, so the
import was unresolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:08:29 -04:00
bvandeusenandClaude Opus 4.7 c33c1178ab fix(android): split MinstrelNavGraph into NavGraphBuilder extensions
detekt LongMethod (97/60 lines). Body had no actual logic — just 13
sequential composable<T>() route declarations. Split by route category
(matches the existing comment headers):

  - inShellTopLevel(navController, expandPlayer) — 8 in-shell tabs
  - inShellDetail(expandPlayer) — 6 push-on-top detail screens
  - outsideShell() — NowPlaying (with slide-up transitions), Queue,
    ServerUrl, Login

`MinstrelNavGraph` itself is now 15 lines (NavHost shell that delegates
to the three groups), and each helper sits well under the 60-line cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:26:55 -04:00
bvandeusenandClaude Opus 4.7 ab05b7ab7b refactor(android): drop bottom nav for AppBar+actions+kebab; shell wrapper (sub-task 2)
Aligns with Flutter — there is no bottom nav, drawer, or rail. Every
in-shell screen carries `MainAppBarActions` in its AppBar:

  [Home] [Library] [Search] [ ⋮ Playlists / Discover / Settings / Admin ]

The icon for the current screen is suppressed so the action set looks
contextual.

  - shared/widgets/MainAppBarActions.kt — NEW. The icon row + kebab.
    Takes navController + currentRouteName (FQN) so each screen tells
    it which icon to hide. Admin kebab entry gated on `isAdmin` for
    later; defaults false.
  - shared/widgets/ShellScaffold.kt — NEW. Wraps any in-shell screen
    with the MiniPlayer pinned at the bottom (auto-hides on no-track,
    matches Flutter `_ShellWithPlayerBar`). Banners slot reserved for
    later (VersionTooOld / UpdateBanner).
  - nav/Routes.kt — added Discover, Playlists, Requests, Admin,
    AdminRequests, AdminQuarantine, AdminUsers, ServerUrl. Grouped
    by shell-vs-full-screen comments.
  - nav/MinstrelNavGraph.kt — every in-shell route wrapped in
    ShellScaffold. NowPlaying becomes a full-screen route with
    slideInVertically/slideOutVertically transitions (mirrors
    Flutter's CustomTransitionPage modal). Queue / ServerUrl / Login
    are also outside the shell.
  - MainActivity.kt — drops Scaffold + NavigationBar + the BottomBarTab
    list. Just hosts the NavHost now. The MiniPlayer moves into
    ShellScaffold per the Flutter pattern.
  - library/ui/LibraryScreen.kt — wraps itself in Scaffold + TopAppBar
    + MainAppBarActions (currentRouteName = Library FQN). Accepts
    navController; album/artist taps navigate via the controller
    instead of via callbacks. Body content still transitional — the
    proper 5-tab restructure lands in sub-task 4.

Routes-without-real-screens (Home, Search, Discover, Playlists,
Settings, Admin, Requests, AdminRequests/Quarantine/Users, Queue,
ServerUrl, Login) all render ComingSoon stubs via EmptyState so the
nav graph is fully reachable end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:55:44 -04:00
bvandeusenandClaude Opus 4.7 2ab20e3958 refactor(android): split tokens into Dark/Light/Flat + LocalFabledSwordTheme + light mode
Sub-task 1 of the design-drift correction. Adds light theme support
and adopts the Flutter pattern of consuming tokens directly via a
theme-extension equivalent (CompositionLocal data class) rather than
fitting everything into Material's ColorScheme roles.

Files:
  - theme/FabledSwordTokens.kt — split the monolith into three
    objects: FabledSwordDarkTokens (#14171A obsidian, etc.),
    FabledSwordLightTokens (#F8F5EE obsidian, #14171A parchment — note
    the semantic inversion, names are roles not literal colors),
    FabledSwordFlatTokens (moss/bronze/oxblood/warning/error/info/
    accent/onAction + radii). The old FabledSwordTokens object is
    kept as a @Deprecated alias re-exporting dark+flat — existing call
    sites compile with a warning until migrated.
  - theme/FabledSwordTheme.kt — NEW. Data class holding the full
    14-color set + radii, with Dark/Light companion factories.
    LocalFabledSwordTheme CompositionLocal exposes the active variant.
  - theme/MinstrelTheme.kt — both darkColorScheme and lightColorScheme
    defined; MinstrelTheme composable accepts darkOverride and falls
    back to isSystemInDarkTheme(). Provides LocalFabledSwordTheme +
    keeps LocalActionColors for back-compat.
  - widget files (AlbumCard, MiniPlayer, NowPlayingScreen,
    ActionColors) migrated to import FabledSwordFlatTokens directly
    for radii / action colors (mode-independent).

Sub-tasks 2-4 (shell, Home, Library tabs) will follow, each its own
commit. Detail screens stay as stubs until their feature phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:40:17 -04:00
bvandeusenandClaude Opus 4.7 c7dc525157 fix(android): use explicit serializer form for encodeToString
Adding `<ResumePayload>` to encodeToString made it bind to the wrong
overload — the compiler picked `encodeToString(SerializationStrategy<T>,
T)` and tried to treat `payload` as the serializer. Switched to the
explicit form:

  json.encodeToString(ResumePayload.serializer(), payload)

Always unambiguous. (Decode is fine — `decodeFromString<T>(String)`
isn't overloaded the same way.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:58:46 -04:00
bvandeusenandClaude Opus 4.7 ae1fa4df98 fix(android): explicit type param on json.encodeToString in ResumeController
Kotlin 2.3 + kotlinx.serialization 1.7 — the reified overload of
`Json.encodeToString(value: T)` failed to infer T from the argument's
type at the call site (the compiler resolved to the
SerializationStrategy + value overload and complained both about
the type mismatch and the missing 'value' arg). Specifying
`<ResumePayload>` explicitly resolves to the correct overload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:35:37 -04:00
bvandeusenandClaude Opus 4.7 e2f87ce940 style(android): consolidate ResumeController.restore returns into runCatching
restore() had 3 explicit returns (row null + decode-failure + empty
tracks) — over detekt's ReturnCount limit of 2. Folded the decode
+ empty-check into a single runCatching chain:

  runCatching { decode }
    .onFailure { dao.clear() side-effect }
    .getOrNull()
    ?.takeIf { tracks.isNotEmpty() }
    ?: return

Two returns now (row missing + the chain result null). Cleaner read
too — the side effect of dropping a corrupt row is right next to the
decode it guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:27:06 -04:00
bvandeusenandClaude Opus 4.7 2a28d22a2a feat(android): ResumeController + persist last queue (M8 phase 6.5 — closes Phase 6)
Phase 6 closes. A torn-down player session now resumes the last queue
on next app launch — the equivalent of the Flutter ResumeController's
job, but plumbed via PlayerController's StateFlow rather than the
audio_service idle-stop dance.

Files:
  - models/TrackRef.kt: add @Serializable so List<TrackRef> can be
    JSON-encoded by the persistence path (mild leak of persistence
    concern into the domain type; alternative duplicate-DTO approach
    not worth the boilerplate yet).
  - player/ResumePayload.kt: @Serializable persisted shape
    (schema version + tracks + queueIndex + positionMs + source).
    `schema` field lets future schema drift drop unreadable rows
    gracefully rather than crash.
  - player/ResumeController.kt: collects PlayerController.uiState;
    persists when (currentTrack id, queueIndex, queue.size) changes —
    captures real session transitions without churning on the 1Hz
    position tick. restore() decodes the row and calls
    PlayerController.setQueue. Catches SerializationException +
    drops the row on schema drift.
  - cache/db/DatabaseModule.kt: @Provides CachedResumeStateDao bridge.
  - MinstrelApplication: @Inject ResumeController + ApplicationScope
    CoroutineScope; onCreate launches resumeController.restore().
    Injecting forces Hilt to construct the singleton so its
    observe-and-persist init block runs.

No circular DI — ResumeController depends on PlayerController, not
the other way around.

This closes Phase 6 of the M8 native rewrite. The player layer is
feature-complete enough to demo on a device once playback wiring
arrives (Phase 11 settings → server URL, Phase 12 sync controller →
library data, and a "Play this album" affordance — none of which
exist yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:22:59 -04:00
bvandeusenandClaude Opus 4.7 5736bff174 style(android): extract CoverPlaceholder + TrackHeader to satisfy detekt LongMethod
NowPlayingScreen was 74 lines vs detekt's 60 threshold. Split into
three logical pieces: CoverPlaceholder, TrackHeader, and the
top-level Column orchestrator (which now stays under threshold and
reads more clearly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:00:54 -04:00
bvandeusenandClaude Opus 4.7 079fc1e4ed feat(android): PlayerViewModel + MiniPlayer + NowPlayingScreen (M8 phase 6.4)
First visible player surfaces.

  - player/ui/PlayerViewModel.kt — thin HiltViewModel wrapping the
    singleton PlayerController. Both MiniPlayer and NowPlayingScreen
    hiltViewModel() one of these; the underlying state is shared by
    construction (controller is process-singleton).
  - player/ui/MiniPlayer.kt — collapsed bar above the bottom nav.
    Returns nothing when no track is loaded (zero footprint on fresh
    install). Tap body → navigate(NowPlaying). Cover-art slot is a
    Lucide placeholder for now; covers wire up when AlbumRef joins
    land in a later 5.x slice.
  - player/ui/NowPlayingScreen.kt — full-screen player. Square cover
    (placeholder), title + artist + album, scrubber (Slider with
    seek-on-release), transport row (prev / play-pause / next).
    EmptyState fallback when no track. Play/pause button uses
    LocalActionColors.primary (Moss) per design-system rule.
  - MainActivity: Scaffold bottomBar slot now wraps MiniPlayer +
    MinstrelBottomBar in a Column so the mini sits above the nav.
  - MinstrelNavGraph: NowPlaying composable now renders the real
    screen instead of the "Coming soon" stub.

scrubber-position-while-playing is event-driven for now (Media3
batches via Player.Listener.onEvents). A periodic 1Hz refresh for
smooth scrubber animation can come later if it's wanted; functional
seeking + position display work without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:55:29 -04:00
bvandeusenandClaude Opus 4.7 6aec03fc02 style(android): clear detekt findings in PlayerController
- Two TooGenericExceptionCaught: connectAndObserve + connectController
    both catch Exception over Media3 IPC boundaries where specific-
    exception handling buys nothing (ListenableFuture.get() throws
    ExecutionException / InterruptedException / CancellationException —
    all forwarded uniformly). Widened to Throwable and @Suppressed with
    a one-line rationale each.
  - MaxLineLength: refactored TrackRef.toMediaItem's nested
    `.apply { source?.let { setExtras(Bundle()...) } }` chain into a
    pair of expression-bodied helpers (metadata builder + sourceExtras).
    Reads cleaner; under 120 chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:22:14 -04:00
bvandeusenandClaude Opus 4.7 4fde634074 feat(android): PlayerController + PlayerUiState (M8 phase 6.3)
Hilt-singleton facade over Media3 MediaController (the IPC client to
MinstrelPlayerService's MediaSession). One process-wide controller +
one StateFlow projection means ViewModels don't each attach their
own Player.Listener.

PlayerUiState: data class with currentTrack / queue / queueIndex /
isPlaying / isBuffering / positionMs / durationMs / bufferedPositionMs
/ playbackError. The mini-player + NowPlayingScreen (Phase 6.4) read
this; the rest of the app sees one consistent player snapshot.

PlayerController:
  - init: async connectAndObserve via suspendCancellableCoroutine
    bridging Media3's ListenableFuture<MediaController>.buildAsync().
    Skips the kotlinx-coroutines-guava dep (Runnable::run is a direct
    executor; the listener just unparks our continuation).
  - Transport methods (play/pause/seekTo/skipToNext/skipToPrevious)
    are no-ops until the controller connects; safe to call early.
  - setQueue(tracks, initialIndex, source) — TrackRef -> MediaItem
    with mediaId + uri + metadata; source tag goes in extras for the
    server-side rotation reporter (#415 parity).
  - Player.Listener.onEvents drives uiState snapshot — Media3 batches
    related events so we don't churn the StateFlow per-event.
  - queueRefs kept as our own list so the UiState projection has
    domain TrackRefs (Media3 has MediaItems internally).

No tests yet — PlayerController's main behavior is IPC-mediated and
benefits from an instrumented test (Robolectric or device). JVM
unit tests for it would mostly mock the MediaController and verify
trivial method-forwarding. Deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:06:46 -04:00
bvandeusenandClaude Opus 4.7 85b8452b78 feat(android): MinstrelPlayerService — Media3 MediaSessionService (M8 phase 6.2)
The actual replacement for everything audio_service plugin wrapped.
Media3 owns the foreground-service lifecycle, MediaSession token,
notification card, lock-screen surface, Bluetooth/AVRCP routing,
Pixel Watch tile, and Android Auto adapter natively — no plugin
layer between us and the platform.

Service shape (~25 LOC):
  - @AndroidEntryPoint MediaSessionService
  - @Inject PlayerFactory builds ExoPlayer in onCreate
  - onGetSession returns the live MediaSession to any binding
    controller (system UI, Wear OS companion, MediaController3 clients)
  - onTaskRemoved keeps playing while audio is active (standard
    media-app behavior); otherwise stopSelf so notification clears
  - onDestroy releases session + player

Compare with flutter_client/lib/player/audio_handler.dart's 1000+ LOC
across MinstrelAudioHandler + the soft-teardown / stall-watchdog /
recovery machinery. Media3 owns most of that natively; we'll get to
the small portions we still need (queue management, position
reporting facade) in 6.3.

Manifest registration: foregroundServiceType="mediaPlayback" +
MediaSessionService intent-filter. MediaButtonReceiver is registered
by the Media3 library; no manual receiver class needed (Flutter's
manifest had to declare audio_service's receiver explicitly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:26:47 -04:00
bvandeusenandClaude Opus 4.7 327ecb6757 style(android): rename package audio_cache -> audiocache
detekt's PackageNaming rule rejects underscores in package names
(Kotlin/Java convention is lowercase, no separator). Renamed
com.fabledsword.minstrel.cache.audio_cache -> .audiocache.

Pattern for future multi-word subpackages: smush rather than _
separator (e.g. mutationqueue, synccontroller, when those land).
The on-disk audio_cache/ dir path inside the app cache (PlayerFactory.kt:41)
is unaffected — that's a filename string, not a package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:16:23 -04:00
bvandeusenandClaude Opus 4.7 f031b186ba feat(android): PlayerFactory + CacheConfig + Media3 1.10.1 bump (M8 phase 6.1)
First Media3 wiring. PlayerFactory builds the process-singleton
ExoPlayer with our shared OkHttp + SimpleCache chain; the
MinstrelPlayerService (Phase 6.2) calls build() in onCreate.

Chain shape:
  ExoPlayer
    .setMediaSourceFactory(DefaultMediaSourceFactory + CacheDataSource)
    .setAudioAttributes(USAGE_MEDIA + CONTENT_TYPE_MUSIC, focus=true)
    .setHandleAudioBecomingNoisy(true)

  CacheDataSource
    .setCache(SimpleCache(audio_cache dir, LRU evictor, Room standalone DB))
    .setUpstreamDataSourceFactory(OkHttpDataSource over shared OkHttp)
    .setCacheWriteDataSinkFactory(CacheDataSink full-fragment)

Built-in audio focus + becoming-noisy handling — Media3 owns these so
no audio_session-equivalent code path is needed (the Flutter app had
~40 LOC for the same; here it's three lines of config).

simpleCache exposed as a PlayerFactory val so the AudioCacheEviction
Worker (Phase 12.3) can call removeSpan() during 2-bucket eviction.

CacheConfig (defaults 200MiB liked + 150MiB rolling) — Phase 11
Settings will let users override.

Bumped Media3 1.4.1 -> 1.10.1 (current stable, AGP 9 + Kotlin 2.3
friendly, MediaSessionService now extends LifecycleService which
makes 6.2's lifecycle-aware patterns cleaner). Breaking changes
between 1.4 and 1.10 don't affect our usage (DRM, FrameExtractor,
ChannelMixingMatrix — we use none).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:23:36 -04:00
bvandeusenandClaude Opus 4.7 0742b45e3d feat(android): nav graph + bottom bar (M8 phase 5.5 — closes Phase 5)
Last task of Phase 5. The app now has the full bottom-bar shell with
NavHost wiring.

  - nav/Routes.kt — @Serializable destinations: top-level tabs
    (Home/Library/Search/Settings), detail screens with id args
    (AlbumDetail, ArtistDetail, PlaylistDetail), overlays
    (NowPlaying, Queue, Login).
  - nav/MinstrelNavGraph.kt — NavHost with composable<RouteType>()
    destinations. Library wires to the real LibraryScreen; everything
    else uses the shared EmptyState as a "Coming soon" placeholder.
  - MainActivity — Scaffold with NavigationBar bottom bar.
    Selected-tab tracking via currentBackStackEntryAsState +
    NavDestination.hasRoute(KClass) (type-safe routes API in
    nav-compose 2.8+).
  - Library cards' onArtistClick / onAlbumClick now navigate to
    ArtistDetail(id) / AlbumDetail(id) — stubs for now, lit up when
    those detail screens land.

Bottom-bar icons (Lucide CMP):
  - House, LibraryBig, Search, Settings

startDestination = Library so the new UI is the cold-start landing
spot until the Home screen lands in Phase 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:04:44 -04:00
bvandeusenandClaude Opus 4.7 18bca6c2fd fix(android): switch Lucide artifact android -> cmp for ImageVector API
The icons-lucide-android variant ships icons as XML Vector Drawables
accessed via painterResource(R.drawable.lucide_x). My Compose code
uses the ImageVector API (`Icon(Lucide.Disc3, ...)`) which is provided
by the icons-lucide-cmp variant. "CMP" (Compose Multiplatform) works
fine in pure-Android Compose — the variant name describes the icon
representation, not a multiplatform-required runtime.

Switched the catalog entry; consuming code (AlbumCard, ArtistCard,
EmptyState, ErrorRetry) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:43:00 -04:00
bvandeusenandClaude Opus 4.7 e934da30a1 feat(android): Library Compose screens + Coil OkHttp sharing + Lucide (M8 phase 5.4)
First user-visible UI. LibraryScreen renders the LibraryViewModel
UiState into horizontally-scrolling Artist / Album rows; Loading
shows a centered spinner, Empty / Error fall back to shared widgets.

Files:
  - library/ui/LibraryScreen.kt — top-level screen, hiltViewModel
    + collectAsStateWithLifecycle, exhaustive when(state)
  - library/widgets/ArtistCard.kt — circular cover + name beneath
  - library/widgets/AlbumCard.kt — 144dp square cover + title +
    artist beneath, matches Flutter spec (~176dp tile width)
  - shared/widgets/EmptyState.kt — generic empty-state widget
    (Lucide Inbox by default), used by Library + reusable for
    Quarantine / search etc.
  - shared/widgets/ErrorRetry.kt — error message + retry button
    (uses LocalActionColors.primary = Moss per design system rule)

Audit-deferred items now triggered:
  - MinstrelApplication implements SingletonImageLoader.Factory and
    wires OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })
    so Coil cover-art requests reuse the shared auth-bearing OkHttp
  - Lucide icons via com.composables:icons-lucide-android:2.2.1 for
    placeholder / decorative iconography

MainActivity now renders LibraryScreen inside a Scaffold (not the
"phase 1" text placeholder). Nav-graph wiring deferred to Phase 5.5
— onArtistClick / onAlbumClick are no-op for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:38:08 -04:00
bvandeusenandClaude Opus 4.7 b03d4a86e7 fix(android): drop Loading-state assertions in LibraryViewModelTest
The three failing assertions tested an implementation detail. Under
UnconfinedTestDispatcher (MainDispatcherExtension's default), stateIn's
upstream Flow runs synchronously when the first subscriber attaches,
so the `Loading` initialValue gets replaced by the upstream emission
before Turbine's .test{} sees it. The observable behavior we care
about is the resolved state — Empty/Success/Error — not the
intermediate Loading.

Tests now collect the resolved state as the first awaitItem(), which
is what users actually see. The Loading state still exists in
production (StateFlow initialValue is preserved across the brief
window before stateIn collects the first upstream value when the
real dispatcher isn't unconfined).

Also cleared two compile warnings the run surfaced:
  - AuthCookieInterceptorTest: added @OptIn(ExperimentalCoroutinesApi)
    for UnconfinedTestDispatcher
  - LibraryRepositoryTest: hoisted the Json instance into a companion
    object (detekt warned about per-call creation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:19:52 -04:00
bvandeusenandClaude Opus 4.7 9962ec981c fix(android): @Provides for the three library DAOs LibraryRepository needs
LibraryRepository @Inject-constructs with CachedArtistDao, CachedAlbumDao,
CachedTrackDao. Hilt errored at hiltJavaCompileDebug with "MissingBinding"
for all three — Phase 4 only added the @Provides bridge for
AuthSessionDao in slice 10 (its single consumer).

Same one-line bridge per DAO: `db.<dao>()` from the AppDatabase
accessor. Future DAOs land in DatabaseModule when their first
@Inject-constructed consumer appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:12:58 -04:00
bvandeusenandClaude Opus 4.7 0ea0fbf8be feat(android): LibraryViewModel + UiState + MainDispatcherExtension (M8 phase 5.3)
First Hilt-injected ViewModel + sealed UiState pattern.

LibraryUiState (sealed interface): Loading / Empty / Success / Error.
The cases are exhaustive so Compose `when` blocks the compiler checks.

LibraryViewModel:
  - combine(observeArtists, observeAlbums) → Success/Empty decision
  - .catch translates upstream Flow exceptions to UiState.Error
  - .stateIn(viewModelScope, WhileSubscribed(5_000), Loading) — the
    standard Compose-friendly pattern; subscriptions tear down 5s after
    the last collector to ride out config changes without hanging the
    DAO Flow forever.

MainDispatcherExtension — JUnit 5 equivalent of the JUnit 4
MainDispatcherRule pattern (audit-deferred item; trigger met). Swaps
Dispatchers.Main for UnconfinedTestDispatcher in beforeEach +
resetMain in afterEach. Apply with `@ExtendWith`.

LibraryViewModelTest covers all four UiState cases — initial Loading,
empty cache (Empty), populated cache (Success), and an upstream Flow
exception (Error). MockK for the repo, Turbine for the Flow assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:38:59 -04:00
bvandeusenandClaude Opus 4.7 9eaaf93f23 feat(android): LibraryRepository + domain types + mappers (M8 phase 5.2)
Cache-first reads of artists/albums/tracks. The Room DAOs are the source
of truth ViewModels observe; refreshArtistDetail / refreshAlbumDetail
pull from the server and upsert into Room — Flow emissions propagate
automatically.

  - models/TrackRef.kt, models/ArtistRef.kt, models/AlbumRef.kt — domain
    types mirroring flutter_client/lib/models/. `Ref` suffix matches
    Flutter convention (lightweight reference, not full per-row metadata).

  - library/data/LibraryMappers.kt — wire->entity (for sync writes),
    entity->domain (for cache reads in ViewModels), wire->domain (for
    fresh server responses bypassing cache), detail-wire->entity (drops
    embedded array, repository upserts those separately).

  - library/data/LibraryRepository.kt — Hilt-injected, observe* Flow
    methods + suspend refresh* methods that upsert through the relevant
    DAOs. Constructs its own LibraryApi via `retrofit.create()` per the
    "repos own their interfaces" pattern adopted in NetworkModule.

  - LibraryRepositoryTest.kt — MockK + Turbine + MockWebServer.
    Verifies the Flow mapping, the wire->entity upsert split, and the
    null-on-miss case for getArtist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:27:47 -04:00
bvandeusenandClaude Opus 4.7 d08c937f3b fix(android): KDoc nested-comment trap from /api/* literal
Kotlin (unlike Java) supports nested block comments. The doc-comment
on LibraryApi contained the string `/api/*` and `/api/home`-style
paths, which the lexer parsed as opening nested comments:

  /**
   * Retrofit interface for the server's native /api/* library surface.  ← lexer: nested /* opens
   ...
   */                                                                    ← closes the nested one
  // outer comment now unclosed; "Unclosed comment" reported at EOF

This compile error is what caused all the "ModuleProcessingStep was
unable to process NetworkModule because LibraryApi could not be
resolved" failures over the last four commits — KSP runs before
compileDebugKotlin and reports the downstream symptom (unresolvable
symbol) before the actual source-level error gets to print.

Rewrote the doc-comment to use `/api/...` and to wrap concrete paths
in backticks; no `/*` substring remains.

The "repos construct their Retrofit interface from shared Retrofit"
pattern from the previous commit stays; it's a sound pattern arrived
at via the wrong reasoning, but defensible on its own merits (fewer
Hilt bindings, locality of reference, easier test override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:41:37 -04:00
bvandeusenandClaude Opus 4.7 02175b193b fix(android): drop provideLibraryApi @Provides — repos construct from Retrofit
The "ModuleProcessingStep was unable to process NetworkModule because
LibraryApi could not be resolved" failure under KSP2 + Hilt 2.59.2
turns out to be specific to @Provides returning a hand-written Kotlin
interface that carries no KSP-processed annotations. Hilt's
ModuleProcessingStep resolves the return type through KSP2's API and
gets an ERROR type for source-only interfaces in some configurations
(google/dagger#4303 cluster).

Two source-of-truth interfaces I tested side-by-side:
  - AuthSessionDao (@Dao, Room-processed) — @Provides works
  - LibraryApi (only @GET Retrofit annotations, no KSP processor) — fails

Workaround that's actually a better pattern: feature repositories
construct their Retrofit interface from the Hilt-injected shared
Retrofit instance. Fewer bindings in the Hilt graph; one Retrofit
interface lives next to its sole consumer.

LibraryApi.kt + wire types remain; LibraryRepository (Phase 5.2) will
hold the `retrofit.create<LibraryApi>()` call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:05:53 -04:00
bvandeusenandClaude Opus 4.7 fe878a392c fix(android): split wire types — one declaration per file
Hypothesis for the KSP2 "LibraryApi could not be resolved" failure:
ArtistWire.kt and AlbumWire.kt each declared TWO @Serializable
classes (the Ref and the Detail variant). LibraryApi imports the
Detail variants but the file names match the Ref variants. KSP2's
symbol indexing may key on `className.kt` and fail to surface the
second declaration in a multi-class file.

Splitting per the MatchingDeclarationName convention:
  - ArtistDetailWire.kt (new)
  - AlbumDetailWire.kt (new)
  - ArtistWire.kt / AlbumWire.kt now contain only their namesake type

If this fixes it, the LibraryApi resolution will work without
changing the @Provides signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:57:04 -04:00
bvandeusenandClaude Opus 4.7 ee72459881 fix(android): use retrofit.create<T>() extension in provideLibraryApi
CI hit a KSP/Hilt resolution error on the prior commit:
  ModuleProcessingStep was unable to process 'NetworkModule' because
  'LibraryApi' could not be resolved.

Switching from `retrofit.create(LibraryApi::class.java)` to the Kotlin
extension `retrofit.create()` (with explicit `LibraryApi` return type
annotation). The extension is reified and may sidestep whatever
type-resolution path the previous form tripped under KSP2 + Hilt.

If this also fails, the next step is to split AlbumWire.kt and
ArtistWire.kt so each file has a single top-level declaration —
investigating cross-file symbol-resolution order in KSP2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:03:59 -04:00
bvandeusenandClaude Opus 4.7 a4c20816bc feat(android): LibraryApi Retrofit interface + wire types (M8 phase 5.1)
Mirrors flutter_client/lib/api/endpoints/library.dart 1:1.

Wire types (snake_case @SerialName per server JSON):
  - TrackWire — id/title/album/artist/duration/streamUrl + nullable
    track/disc numbers (fields verified against TrackRef.fromJson in
    flutter_client/lib/models/track.dart)
  - ArtistWire / ArtistDetailWire — the detail shape embeds "albums"
  - AlbumWire / AlbumDetailWire — the detail shape embeds "tracks"

The Detail variants are explicit data classes (rather than a generic
envelope) because the server returns ArtistRef fields PLUS the
embedded array in the same object, which kotlinx.serialization can't
deserialize through a polymorphic envelope.

LibraryApi endpoints:
  - getTrack(id)
  - getArtistDetail(id) — ArtistDetailWire
  - getArtistTracks(id) — bare List<TrackWire> (server emits a bare
    array, NOT enveloped; Retrofit handles it via List return type)
  - getAlbumDetail(id) — AlbumDetailWire
  - shuffleLibrary(limit) — bare List<TrackWire>

Home endpoints (/api/home and /api/home/index) deferred to a future
HomeApi file because they have their own (larger) wire types that
only the Home screen consumes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:56:10 -04:00
bvandeusenandClaude Opus 4.7 156162e3ac feat(android): port auth_session + promote AuthStore to Room (M8 4.2 slice 10)
Last slice. Promotes the Phase 3.1 in-memory AuthStore placeholder to a
Room-backed single-row auth_session table so session cookie + base URL
survive process death.

Design — hybrid storage:
  - MutableStateFlow is the primary read source so interceptor-thread
    reads stay synchronous (no awaiting a DAO call from inside an
    OkHttp interceptor)
  - Writes update the in-memory state synchronously AND launch a
    write-through coroutine that persists to the DAO
  - init() collects dao.observe() to keep in-memory in sync with
    persisted state on app start + any external DB writes

AuthSessionDao gets partial-update queries (`setSessionCookie` /
`setBaseUrl`) so we don't have to round-trip the full row on every
mutation. First write does an upsert to seed the row.

DatabaseModule grows a @Provides for AuthSessionDao — Hilt can't inject
AppDatabase's abstract DAO accessors directly; each consumer-needed DAO
gets a thin bridge.

AuthCookieInterceptorTest updated: AuthStore now takes (dao, scope)
constructor args. Test uses mockk for the DAO and TestScope with
UnconfinedTestDispatcher so the in-memory state mutations the test
asserts on aren't affected by the asynchronous DAO writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:21:42 -04:00
bvandeusenandClaude Opus 4.7 954bb8963f chore(android): extend detekt TooManyFunctions exception to @Database
AppDatabase grew its 12th DAO accessor in slice 9 and tripped the
TooManyFunctions rule. Same shape as the @Dao case from slice 5 —
Room types naturally accumulate one method per entity family. Added
"Database" to the ignoreAnnotated list alongside "Dao".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:45:47 -04:00
bvandeusenandClaude Opus 4.7 e42f2bf525 feat(android): port cached_home_index (M8 4.2 slice 9)
Mirrors flutter_client/lib/cache/db.dart's CachedHomeIndex — per-item
rows that drive the Home screen sections (Recently Added Albums,
Rediscover Albums/Artists, Most Played Tracks, Last Played Artists).

Composite PK (section, position) — exactly one row per slot per
section; sync replaces in-place via upsert. entityType ("album" /
"artist" / "track") dispatches per-tile hydration to the right
per-entity endpoint when the Home screen renders.

DAO surface fits the sync flow:
  - observeBySection (Flow) for the Home composables
  - getBySection (suspend) for one-shot sync reads
  - upsertAll for sync writes
  - deleteBySection for replace-all on a section sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:37 -04:00
bvandeusenandClaude Opus 4.7 f3a0c44460 feat(android): port cached_resume_state + fix CacheSource enum (M8 4.2 slice 8)
Two related fixes from re-reading the Drift source for slice 8:

  1. The plan called this slice "last_played" but the Drift table is
     `cached_resume_state` — kept Drift's name for cross-reference
     during the port. Single-row JSON-blob pattern (queue, currentIndex,
     positionMs, source) — ResumeController (Phase 6.5) handles the
     Kotlin-side encode/decode so the schema stays stable across
     resume-shape evolution.

  2. CacheSource enum was incomplete: Task 4.1 ported only 3 of the 5
     Drift variants. Added AUTO_LIKED + AUTO_PLAYLIST (used by the
     auto-cache prefetcher to tag cached files by reason — drives the
     bucket eviction priority order INCIDENTAL > AUTO_PREFETCH >
     AUTO_PLAYLIST > AUTO_LIKED > MANUAL).

No data migration needed — schema version is still 1 and we have no
real users yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:35:10 -04:00
bvandeusenandClaude Opus 4.7 0b1ccd59b1 feat(android): port cached_mutations (M8 4.2 slice 6)
Mirrors flutter_client/lib/cache/db.dart's CachedMutations — the
offline-write queue that MutationQueue.enqueue() inserts into when a
server-write fails with IOException and MutationReplayer.drain() pops
from when connectivity returns (Phase 12.2).

`kind` is a string registered in `MutationKind` (Phase 12.2) so the
replayer can map to the right handler. `payload` is JSON-serialized
args. Unknown kinds get dropped at drain time rather than wedging.

DAO surface tailored to the replayer:
  - observePendingCount: Flow<Int> for the offline-indicator badge
  - getAll: FIFO list for drain (id ASC = oldest first)
  - insert: returns the autoGenerate'd id
  - recordAttempt(id, instant): atomic increment + lastAttemptAt set
  - delete(id) / clear

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:02:02 -04:00
bvandeusenandClaude Opus 4.7 25a66f7d2e chore(android): allow @Dao interfaces above detekt's TooManyFunctions threshold
AudioCacheIndexDao has 12 methods (default rule threshold is 11) — DAOs
accumulate one method per distinct query and inherently exceed the
default. Scoped via ignoreAnnotated: ["Dao"] rather than raising the
global threshold; the rule still catches non-DAO interfaces that grow
unreasonably wide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:56:53 -04:00
bvandeusenandClaude Opus 4.7 e63034ec9c feat(android): port audio_cache_index (M8 4.2 slice 5)
Mirrors flutter_client/lib/cache/db.dart's AudioCacheIndex — one row
per fully-downloaded audio file. Drives the 2-bucket LRU eviction
policy that Phase 12's AudioCacheEvictionWorker will execute.

DAO surface tailored to the eviction worker:
  - totalBytes / bytesBySource — sum-of-sizeBytes for cap checks
  - evictionCandidates(source) — oldest lastPlayedAt within a source
    bucket, NULL lastPlayedAt sorted first (never-played candidates
    evict before played ones)
  - touchLastPlayed(trackId, instant) — single-column update from
    the player on every "ready+playing" transition
  - bulk delete by track-id list for batch evictions

CacheSource enum (MANUAL / INCIDENTAL / AUTO_PREFETCH) ported in
Task 4.1's TypeConverters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:53:57 -04:00
bvandeusenandClaude Opus 4.7 c18ad19418 feat(android): port cached_quarantine_mine (M8 4.2 slice 4)
Mirrors flutter_client/lib/cache/db.dart's CachedQuarantineMine —
the user's flagged-as-hidden tracks. Server returns the full
denormalized snapshot on /api/me/quarantine; the cache mirrors that
shape so the Quarantine screen renders without a join.

`createdAt` is a server ISO-8601 string (canonical timestamp);
`fetchedAt` is our local sync marker.

DAO covers the three known consumers:
  - observeAll for the Quarantine screen (newest first)
  - observeFlaggedTrackIds for feed-level filtering
  - observeIsHidden(trackId) scalar Flow for per-tile UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:16:11 -04:00
bvandeusenandClaude Opus 4.7 4b38623229 feat(android): port cached_playlists + cached_playlist_tracks (M8 4.2 slice 3)
Two related entities mirroring flutter_client/lib/cache/db.dart:
  - CachedPlaylists — one row per playlist; `systemVariant` is null for
    user playlists and "for_you" / "songs_like_artist" / etc. for
    system-generated mixes (used by the add-to-playlist sheet filter)
  - CachedPlaylistTracks — composite-PK join table, `position` carries
    ordering inside a playlist

DAO surfaces split user vs system playlists at the query layer so
ViewModels don't have to filter — observeUserPlaylists/observeSystemPlaylists.
PlaylistTrackDao gets a deleteByPlaylist for the replace-all pattern
after a sync delta lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:05 -04:00
bvandeusenandClaude Opus 4.7 7b55f586ac feat(android): port cached_likes (M8 4.2 slice 2)
Mirrors flutter_client/lib/cache/db.dart's CachedLikes Drift table —
composite PK (userId, entityType, entityId) so the same user can
like a track and its album and its artist independently. entityType
is a plain string ("track" | "album" | "artist") for parity with the
Drift schema and the server wire format.

DAO surface tailored to consumers we know are coming:
  - observeLikedTrackIds(userId): Flow<List<String>> — audio-cache
    eviction reads this set to identify "liked" bucket members
  - observeLikedIdsOfType(userId, entityType): generalized variant
  - observeIsLiked(...): scalar Flow for LikeButton composables
  - upsertAll (sync writes)
  - delete (mutation queue → toggle off)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:03:42 -04:00
bvandeusenandClaude Opus 4.7 17a3e7dd4f feat(android): port cached_artists / cached_albums / cached_tracks (M8 4.2 slice 1)
Mirrors flutter_client/lib/cache/db.dart's CachedArtists / CachedAlbums /
CachedTracks Drift tables. Library cache foundation — LibraryRepository
(Phase 5.2) reads cache-first through these DAOs and refreshes from
server via the sync controller (Phase 12.4).

Column names follow Kotlin idiom (camelCase) instead of Drift's
snake_case; the schema is internal to the native client and the wire
JSON conversion happens in feature-level mappers.

Each DAO carries:
  - observe* (Flow) for cache-first reads in ViewModels
  - getById/getByIds (suspend) for one-shot lookups
  - upsertAll (suspend, REPLACE) for sync writes
  - deleteByIds (suspend) for sync-driven deletes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:28:54 -04:00
bvandeusenandClaude Opus 4.7 a10079e8ef feat(android): Room foundation — AppDatabase + TypeConverters + Gradle plugin
M8 phase 4.1. Pre-flight research (per the feedback_m8_preflight_research
rule) found two needed adjustments to the original plan:

  1. Room 2.6.1 -> 2.8.4. Room 2.6.1 (Oct 2023) predates Kotlin 2.0
     mainstream; Room 2.7+ explicitly supports Kotlin 2.0+ and KSP2.
     Room 2.8.4 is current stable; minSdk 23 (we're at 26) and AGP 8.4+
     (we're on 9), both compatible.
  2. Use the androidx.room Gradle plugin's `room { schemaDirectory(...) }`
     block instead of the legacy `ksp { arg("room.schemaLocation", ...) }`
     pattern. Cleaner schema-export plumbing in Room 2.7+. Audit-deferred
     item; trigger condition (first Room entity) met here.

Files:
  - cache/db/AppDatabase.kt — @Database stub, schemaVersion 1
  - cache/db/TypeConverters.kt — Instant <-> Long, CacheSource enum
  - cache/db/DatabaseModule.kt — Hilt-provided AppDatabase singleton
  - cache/db/entities/SyncMetadataEntity.kt — pulled forward from
    Task 4.2 slice 7 to satisfy Room's "needs >=1 entity" compile check;
    its consumer (SyncController) lands in Phase 12.4
  - cache/db/dao/SyncMetadataDao.kt — minimal observe/get/upsert
  - libs.versions.toml — Room 2.8.4, androidx-room plugin alias
  - app/build.gradle.kts — apply androidx.room plugin, add room {}
    block, drop ksp room.schemaLocation arg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:22:11 -04:00
bvandeusenandClaude Opus 4.7 4cbbdbfef9 chore(android): audit-response wave — JUnit launcher, compileSdk 36, CC warn, image :36, Gradle cache
Fixes uncovered by the 2026-05-22 build-config audit + the Gradle 9 +
JUnit Platform launcher requirement that just surfaced in CI:

  - testRuntimeOnly junit-platform-launcher: Gradle 9 no longer auto-
    injects it; tests fail with "Failed to load JUnit Platform" without
    an explicit dep.
  - compileSdk + targetSdk 35 -> 36: AGP 9 defaults to 36 and warns on
    lower values; CI was also auto-downloading build-tools 36 at
    runtime (now pre-installed in ci-android:36).
  - container.image bumped to ci-android:36 to match.
  - configuration-cache.problems=warn in gradle.properties: detekt 2.0-
    alpha + ktlint Gradle plugin have CC compat holes; warn rather
    than fail.
  - androidTest dep parity: kotlin("test") + kotlinx-coroutines-test
    added (was on testImplementation only).
  - CI: actions/cache@v4 for ~/.gradle/{caches,wrapper} + ~/.kotlin,
    keyed on gradle-wrapper.properties + libs.versions.toml + the
    *.gradle.kts files. Saves ~3 min per run after warm-up.

Deferred (with trigger conditions, will land when needed): Hilt
testing artifacts + HiltTestRunner (first @HiltAndroidTest), Room
Gradle plugin + schemaDirectory (Phase 4.2), Coil 3 ImageLoader
factory sharing OkHttp (Phase 5.4), MainDispatcherRule test utility
(Phase 5.3), JUnit-5/4 split for instrumented (Phase 5+),
NetworkSecurityConfig (pre-cutover Phase 14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:41:00 -04:00
bvandeusenandClaude Opus 4.7 41af466621 fix(android): add kotlin-test test dep for assertEquals/assertNull
AuthCookieInterceptorTest imports `kotlin.test.assertEquals` /
`kotlin.test.assertNull` which weren't resolving without the explicit
kotlin-test dep. `kotlin("test")` is sourced from the applied Kotlin
plugin (built-in via AGP 9), so no version pin needed.

Hilt + KSP code generation worked correctly in the prior run —
hiltAggregateDepsDebug succeeded; the failure was purely test-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:06:53 -04:00
bvandeusenandClaude Opus 4.7 53f69fb1f5 feat(android): NetworkModule + AuthCookieInterceptor + AuthStore placeholder
M8 phase 3.1. Wires the single shared OkHttp + Retrofit instance the
whole app uses (per-endpoint Retrofit interfaces land in feature
modules). Audio HTTP via ExoPlayer's OkHttpDataSource.Factory will
reuse this same client — single auth/connection-pool surface.

  - AuthStore: in-memory MutableStateFlow placeholder. Task 4.2
    promotes it to a Room-backed single-row table for process-death
    persistence; public API stays identical.
  - AuthCookieInterceptor: attaches Cookie on outbound, captures
    Set-Cookie on successful responses (login flow), clears the store
    on 401 (logout signal).
  - ServerBaseUrl: value class to type-safely DI the base URL.
  - NetworkModule: Hilt-provided OkHttp + Retrofit + HttpLogging.

First task with unit tests — AuthCookieInterceptorTest uses MockWebServer
to verify all three interceptor branches plus a no-Set-Cookie-no-overwrite
case. Will tell us if the JUnit 5 + okhttp-mockwebserver test stack is
plumbed correctly through Gradle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:00:41 -04:00
bvandeusenandClaude Opus 4.7 25ae70c5bd fix(android): clear detekt findings — rename files, wrap long lines, allow Composable PascalCase
Seven findings from the first real detekt run:

  - LocalActionColors.kt / Tokens.kt: MatchingDeclarationName flagged
    that the file names don't match the single top-level declaration.
    Renamed to ActionColors.kt and FabledSwordTokens.kt (`git mv`).
  - Typography.kt: three Font(...) calls exceeded the default 120-char
    line length. Wrapped each named-arg list onto its own line.
  - MainActivity.kt + MinstrelTheme.kt: FunctionNaming flagged App() /
    MinstrelTheme() for not starting lowercase — these are
    @Composable functions and PascalCase is the Compose convention.
    Added a config override to the detekt YAML:

      naming:
        FunctionNaming:
          ignoreAnnotated: ['Composable']

    Matches every mainstream Compose codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:33:35 -04:00
bvandeusenandClaude Opus 4.7 1d9204897c fix(android): remove pre-2.0 build: section from detekt config
detekt 2.0 removed the top-level `build:` key:

  Property 'build' is misspelled or does not exist. Allowed properties:
  [comments, complexity, config, console-reports, coroutines,
   empty-blocks, exceptions, naming, performance, potential-bugs,
   processors, style].

The `build.maxIssues = 0` setting we had moved to the Gradle plugin DSL
(`failOnSeverity` option, defaults to Error). Emptied the YAML; rely on
detekt defaults via `buildUponDefaultConfig = true` until we have
specific rule overrides to write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:19:18 -04:00
bvandeusenandClaude Opus 4.7 a68eb1c533 fix(android): migrate detekt block + task types to 2.0 DSL
Three breaking changes I missed when bumping to 2.0.0-alpha.3:

  1. Gradle plugin id changed: io.gitlab.arturbosch.detekt -> dev.detekt
  2. Task FQN changed: io.gitlab.arturbosch.detekt.Detekt
     -> dev.detekt.gradle.Detekt
     (same for DetektCreateBaselineTask)
  3. jvmTarget is now a Property API (.set("17")) instead of var assignment

Also dropped `autoCorrect` from the detekt {} block — it's not in the
2.0 options list per the official getting-started docs.

Per the 2.0 release notes: "the workaround of disabling the new DSL
and built-in Kotlin via gradle.properties for AGP 9.x projects is no
longer required" — so our existing AGP 9 + built-in-Kotlin setup is
expected to work cleanly with detekt 2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:13:36 -04:00
bvandeusenandClaude Opus 4.7 f210cbbc3e chore(android): bump detekt 1.23.8 -> 2.0.0-alpha.3 for Kotlin 2.3.21 + JDK 25
1.23.8 still failed on JDK 25 with "25.0.3" — same opaque shape as the
original Gradle 8.10 failure. The 1.23.x line bundles kotlin-compiler-
embeddable 1.9.10 which doesn't actually run on JDK 25 despite the
release note claim.

2.0.0-alpha.3 is explicitly built against Kotlin 2.3.21 + Gradle 9.3.1
+ tested with JDK 25 (per release notes). Alpha is acceptable risk
given there's no stable 2.x and we're already on bleeding-edge AGP 9
+ Kotlin 2.3 elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:11:13 -04:00
bvandeusenandClaude Opus 4.7 9f0a0009d7 chore(android): bump detekt 1.23.7 -> 1.23.8 for JDK 25 compat
detekt 1.23.7 choked on JDK 25 with an opaque "25.0.3" error (same
shape as the original Gradle 8.10 failure). Per the detekt 1.23.8
release notes (Feb 2025), it's the first 1.23.x version tested with
JDK 25. Still built against Kotlin 2.0.21 — fine for our small Phase 1
sources, no exotic Kotlin 2.3 syntax in use yet.

The `tasks.withType<Detekt> { jvmTarget = "17" }` pin from the
previous commit stays as belt-and-braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:05:45 -04:00
bvandeusenandClaude Opus 4.7 8c51fe0bbe fix(android): pin detekt jvm-target to 17
detekt 1.23.7 bundles kotlin-compiler-embeddable 1.9.10 whose
--jvm-target validator only accepts up to 22. Detekt auto-detected
the runner's JDK 25 and choked. Pin to 17 (matches our
compileOptions.targetCompatibility + kotlin.compilerOptions.jvmTarget).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:58:07 -04:00
bvandeusenandClaude Opus 4.7 627810aee6 style(android): ktlint multiline-expression-wrapping on build.gradle.kts
Two assignments had a multi-line RHS sitting on the same line as the
`=`. ktlint's multiline-expression-wrapping rule requires the
multi-line expression to start on a new line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:55:39 -04:00
bvandeusenandClaude Opus 4.7 6fb2ff2b9c chore(android): bump Kotlin 2.2 -> 2.3 + KSP 2.0 -> 2.3 for AGP 9 built-in Kotlin
The previous attempt opted out of AGP 9's built-in Kotlin (via
android.builtInKotlin=false + explicit kotlin-android plugin) because
the message from Gradle suggested it. But Kotlin 2.2.21's
kotlin-android plugin can't cast AGP 9's new ApplicationExtension to
the removed BaseExtension:

  class ApplicationExtensionImpl$AgpDecorated_Decorated
  cannot be cast to class com.android.build.gradle.BaseExtension

That suggestion is for projects with an older Kotlin toolchain. The
real fix:

  - Kotlin 2.3.21 (latest stable; first line where kotlin-android also
    supports AGP 9, but more importantly the built-in path works)
  - KSP 2.3.8 — KSP PR #2674 (merged Oct 2025) added AGP 9 built-in
    Kotlin support. KSP 1.x and pre-2.3 don't work with built-in Kotlin.
  - Re-drop the kotlin-android plugin from both build.gradle.kts files;
    AGP 9 enables built-in Kotlin by default and KSP 2.3 cooperates.
  - Remove android.builtInKotlin=false from gradle.properties.

compose-compiler plugin tracks the Kotlin version via version.ref, so
no separate bump there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:04:21 -04:00
bvandeusenandClaude Opus 4.7 4071894217 fix(android): opt out of AGP 9 built-in Kotlin — KSP incompatible
AGP 9 enables built-in Kotlin by default (`android.builtInKotlin=true`),
which we initially adopted by dropping the `kotlin-android` plugin
alias. But KSP isn't compatible with built-in Kotlin yet — Gradle
errors out with:

  > KSP is not compatible with Android Gradle Plugin's built-in Kotlin.
  > Please disable by adding android.builtInKotlin=false to gradle.properties
  > and apply kotlin("android") plugin

Restored the explicit kotlin-android plugin (root + :app) and added
`android.builtInKotlin=false` to gradle.properties. Revisit when KSP
gains built-in-Kotlin support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:57:49 -04:00
bvandeusenandClaude Opus 4.7 6dae4b452f fix(android): replace deprecated kotlinOptions with kotlin{compilerOptions}
`kotlinOptions { jvmTarget = "17" }` inside the `android { }` block was
removed in newer Kotlin tooling; replaced with the modern top-level
`kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }` form.

Required after the Kotlin 2.2 + AGP 9 bump; the old DSL was tolerated
through AGP 8.7 + Kotlin 2.0 but not through AGP 9's built-in Kotlin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:55:43 -04:00
bvandeusenandClaude Opus 4.7 3f9c2a8930 chore(android): bump Hilt 2.52 -> 2.59.2 for AGP 9 compat
Hilt 2.52 referenced AGP's old BaseExtension which AGP 9 removed,
causing ktlintCheck to fail at plugin-application time:

  Failed to apply plugin 'com.google.dagger.hilt.android'.
  > Android BaseExtension not found.

Dagger/Hilt 2.59+ adds AGP 9 support (and mandates it for the Gradle
plugin path). 2.59.2 is the current latest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:52:52 -04:00
bvandeusenandClaude Opus 4.7 d8989aa95c chore(android): bump toolchain to Gradle 9.1 + AGP 9.0.1 + Kotlin 2.2.21 for JDK 25
Original 8.x toolchain choked on the ci-android image's JDK 25 with an
opaque "25.0.3" error in `ktlintCheck`; Gradle 8.10's JDK compat matrix
caps at 23. Modern chain:

  - Gradle 9.1.0 (first to support JDK 25)
  - AGP 9.0.1 (requires Gradle 9.1+, requires Kotlin 2.2.10+)
  - Kotlin 2.2.21 / KSP 2.2.21-2.0.5 (latest 2.2.x line)
  - Compose BOM 2026.05.01 (current; pulls ui-text-google-fonts at the
    BOM-managed version, so the explicit pin was dropped)

AGP 9.0 breaking changes that affected us:
  - `kotlin-android` plugin no longer needed — AGP 9 auto-enables via
    `android.builtInKotlin=true` default. Removed alias from both the
    root build.gradle.kts and :app/build.gradle.kts.
  - `applicationVariants` API removed; we don't use it.
  - Other defaults flipped (useAndroidx, uniquePackageNames, etc.) but
    we already set them explicitly or weren't relying on the old defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:45:37 -04:00
bvandeusenandClaude Opus 4.7 2d990690ba ci(android): android.yml workflow — ktlint + detekt + unit tests + release APK
M8 phase 2.1. Runs on every push to dev/main + PR to main (path-filtered
on android/**). Tag pushes (v*) additionally build a signed release APK
attached to the existing Forgejo release as minstrel-android-<tag>.apk
during the side-by-side period; that name flips to minstrel-<tag>.apk
at M8 phase 14.4 cutover.

Reuses ANDROID_KEYSTORE_B64 + STORE/KEY_PASSWORD + KEY_ALIAS secrets so
signing matches flutter.yml — Android accepts upgrade in place at cutover
without uninstall.

runs-on: flutter-ci because that's the only proven-working runner label
on this Forgejo instance with docker. Switch to android-ci once that
label gets registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:22:49 -04:00
bvandeusenandClaude Opus 4.7 f2f6fa06a2 feat(android): FabledSword theme — Material 3 + Google Fonts
M8 phase 1.4. Mirrors flutter_client/lib/theme/. Source of truth for hex
values is flutter_client/shared/fabledsword.tokens.json (manual sync until
cross-language codegen lands; ports the dark-surface + flat cohort).

Material 3 ColorScheme takes accent as primary; action colors
(Moss/Bronze/Oxblood) live in LocalActionColors as semantic roles per the
project_design_system rule "NEVER use accent for action buttons".

Typography uses androidx.compose.ui.text.googlefonts to fetch Fraunces /
Inter / JetBrains Mono at runtime via Play Services Fonts — matches the
Flutter client's `google_fonts` package (no bundled .ttf files in either
tree). Weights restricted to 400/500. Fraunces is reserved for ≥18sp
display/headline slots per the design-system rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:18:15 -04:00
bvandeusenandClaude Opus 4.7 a948a71fa5 feat(android): Hilt application + AppModule (Json + ApplicationScope)
M8 phase 1.3. Plants the Hilt entrypoint so the rest of the modules
(NetworkModule, DatabaseModule, PlayerModule) can land in subsequent
phases. WorkerFactory wired so HiltWorker can be used directly later.

Restores @AndroidEntryPoint on MainActivity (deferred from 1.2 since
Hilt KSP errors without an annotated Application class).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:58:30 -04:00
bvandeusenandClaude Opus 4.7 34e54f29e9 feat(android): :app module skeleton — Compose Activity + manifest
M8 phase 1.2. AndroidManifest declares FGS mediaPlayback +
POST_NOTIFICATIONS permissions ahead of the player phase. Activity
hosts a single Compose Scaffold for now; nav graph lands in phase 5.

Launcher icons reused from flutter_client/ (same applicationId means
same brand at cutover). MinstrelApplication referenced in manifest
but the class itself lands in Task 1.3 — manifest class names are
resolved at install time, not build time, so the intermediate commit
still builds.

@AndroidEntryPoint deferred to Task 1.3 alongside @HiltAndroidApp on
MinstrelApplication (Hilt KSP errors without an annotated Application).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:17:40 -04:00