Commit Graph

1550 Commits

Author SHA1 Message Date
bvandeusen 3cf829752b chore(ci): migrate workflows to gitea + consolidate keystore secret
flutter / analyze-test-build (push) Failing after 2s
test-go / test (push) Successful in 33s
android / Build + lint + test (push) Failing after 41s
test-web / test (push) Successful in 41s
android / Build signed release APK (push) Has been skipped
test-go / integration (push) Successful in 11m16s
- rename .forgejo/workflows/ to .gitea/workflows/ (git mv preserves
  history); Gitea Actions reads either path, but the directory now
  matches the active platform
- collapse ANDROID_STORE_PASSWORD + ANDROID_KEY_PASSWORD into a
  single ANDROID_KEYSTORE_PASSWORD secret (PKCS12 keystores require
  the two passwords to be identical, so the duplication carried no
  information); build.gradle.kts still reads two env vars to stay
  format-agnostic, both now sourced from the same secret in CI
- ignore android/*.keystore, android/*.keystore.*, android/*.jks,
  android/keystore.properties so the regenerated signing material
  never reaches a remote on accident
- update prose references from Forgejo to Gitea in CLAUDE.md and
  the docs; the historical "migrated from Forgejo" note in CLAUDE.md
  is kept intentionally; forgejo/upload-artifact@v3 action refs are
  left untouched (canonical artifact action, resolves cleanly under
  Gitea Actions)

Operator side: ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, and
ANDROID_KEYSTORE_B64 secrets registered in Gitea before this lands.
2026-05-31 23:05:03 -04:00
bvandeusen 96caac2f06 fix(android): suppress MatchingDeclarationName on PlaylistsListScreen (VM colocated) 2026-05-30 22:59:50 -04:00
bvandeusen fa90d6d6c5 refactor(android): extract asCacheFirstStateFlow helper (DRY 3d)
Five cache-first ViewModels all ended their flow pipeline with:

    .catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
    .stateIn(viewModelScope,
             SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
             UiState.Loading)

plus a per-file private const for the 5_000L share-stop timeout.

Extract to a single Flow<UiState<T>> -> StateFlow<UiState<T>> extension
in shared/CacheFirstStateFlow.kt; migrate Library, PlaylistsList,
LikedTab, AddToPlaylist, and Home VMs. Each VM drops the catch +
stateIn + ErrorCopy + SharingStarted imports + the local constant.

AddToPlaylistVM keeps its onStart { refresh; emit Loading } block
in front of the helper -- that re-emit is intentional UX for sheet
re-opens past the 5s subscriber timeout.
2026-05-30 22:56:22 -04:00
bvandeusen 453f8a387b fix(android): remove duplicate TrackActions snackbar wiring (DRY 3c)
ShellScaffold already collects TrackActionsViewModel.transientMessages
into its own SnackbarHost. Per-route, the shell's hiltViewModel<
TrackActionsViewModel>() and the screen's hiltViewModel<TrackActions
ViewModel>() resolve to the same NavBackStackEntry-scoped instance,
so any kebab action fires the snackbar twice today (once in the
screen's own SnackbarHost, once in the shell's).

Strip the per-screen wiring from the four shell-wrapped screens that
had it (Library, Search, AlbumDetail, PlaylistDetail): VM param,
SnackbarHostState, LaunchedEffect, and the Scaffold's snackbarHost.
The shell now owns the snackbar exclusively.

NowPlayingScreen is intentionally left untouched -- it's outside the
shell (full-screen route), so its own snackbar wiring is the only
surface that shows the message.
2026-05-30 22:38:48 -04:00
bvandeusen 02a6c45958 refactor(android): extract MinstrelTopAppBar (DRY 3b)
Ten screens duplicated the same TopAppBar + Text(title) +
optional-back-arrow + MainAppBarActions sandwich. Extract into
MinstrelTopAppBar(title, navController, currentRouteName, onBack,
actions) at shared/widgets/. Library's tab row still composes
above it inside a Column; Library's Shuffle button slots into the
extra actions trailing-lambda; drill-down screens pass onBack for
the back arrow; root tabs leave onBack null.

Net: -48 lines across 10 screens, +50 lines for the helper.

SearchScreen keeps its own TopAppBar (title is a SearchField
composable, not a string).

Per-screen TrackActionsViewModel snackbar wiring (Library/Search/
detail screens) was intentionally NOT consolidated here: each
hiltViewModel() returns a different VM instance than the shell-
scoped one already in ShellScaffold, so dedup belongs in 3c
(shell-scope the VM via CompositionLocal) rather than freezing the
current duplicate into a helper.
2026-05-30 16:00:21 -04:00
bvandeusen 2b17a4ed69 fix(android): AddToPlaylist routes empty list through UiState.Empty
The original AddToPlaylistUiState only had Loading/Success/Error, with
the empty-list message branched inside Success. After migrating to
shared UiState<T> (which adds Empty), the sheet's when over the state
was non-exhaustive. Route empty through UiState.Empty at the VM, drop
the inner branch in the sheet, and add the explicit Empty arm in the
when block to satisfy the compiler.
2026-05-30 14:29:50 -04:00
bvandeusen a52fdc7d5d fix(android): rename LibraryUiState.kt to LibraryData.kt (detekt MatchingDeclarationName) 2026-05-30 14:06:21 -04:00
bvandeusen 810e3126c2 refactor(android): migrate 8 per-screen UiStates to shared UiState<T>
History/Hidden/Requests/PlaylistsList/AddToPlaylist/Home/Liked/Library
each had its own sealed interface with Loading/Empty/Success(payload)/
Error variants. Collapsed to the generic shared/UiState<T> introduced
in the prior commit. Library carries two lists (artists + albums) so
its payload is wrapped in a new LibraryData record; the test was
updated to assert against UiState.Success<LibraryData>.

The 3 detail screens (Artist/Album/Playlist Detail) keep their own
sealed interfaces for now since they include a Loading(seed: ...)
variant that does not fit UiState<T>.
2026-05-30 14:03:39 -04:00
bvandeusen 587baf4a79 refactor(android): add generic shared/UiState<T> sealed type 2026-05-30 11:33:42 -04:00
bvandeusen 730176b1ed fix(android): restore Alignment import in ArtistCard (Column horizontalAlignment) 2026-05-30 10:50:52 -04:00
bvandeusen b71f9c239e fix(android): NowPlaying action row above the scrubber for Flutter parity
Flutter's _SecondaryControls (like, shuffle, repeat, queue, kebab) sit just above the seek bar (now_playing_screen.dart:464); Android had them at the bottom under the transport row. Reorder the NowPlayingBody column so it reads cover -> title -> actions -> scrubber -> transport, matching Flutter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:32:25 -04:00
bvandeusen ee6305f525 refactor(android): extract shared CoverTile from Album/Artist/Playlist cards
The three cards shared an identical Box + clip + background + ServerImage + fallback structure around their cover (only size, shape, fallback icon, and overlay differ). Extract a single CoverTile composable; each card now passes its own size, shape, background, fallback icon, and optional BoxScope overlay (used by PlaylistCard for the VariantPill). Pure DRY consolidation; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:31:32 -04:00
bvandeusen 37887107ee fix(android): pin Slider inactiveTrackColor to surfaceVariant (parity)
Material3 changed the Slider's default inactiveTrackColor to colorScheme.secondaryContainer, which MinstrelTheme does not override -- so M3's baseline purple leaked into both the mini scrubber and the NowPlaying ScrubberRow. Flutter explicitly sets inactiveColor = fs.slate; mirror that by pinning inactiveTrackColor to surfaceVariant (which the theme already maps to slate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:31:16 -04:00
bvandeusen 5c7e659116 refactor(android): hoist resolveServerImageUrl to shared/ServerUrls.kt as resolveServerUrl
It was never image-specific -- it resolves any /api/* relative URL to the placeholder.invalid form that BaseUrlInterceptor rewrites. Both covers (ServerImage) and stream URLs (PlayerController) call it. Move the helper to shared/ServerUrls.kt with the right name; ServerImage and PlayerController import from there. Pure rename + relocate, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:09:07 -04:00
bvandeusen c720dff310 fix(android): resolve relative stream_url before handing to Media3
Server emits stream_url as a relative path (/api/tracks/{id}/stream, internal/api/convert.go:75). PlayerController.toMediaItem passed it raw to setUri, so OkHttpDataSource saw a host-less URI and silently failed -- queue UI loaded but no audio played. Route streamUrl through the same resolver used for covers (resolveServerImageUrl), which prepends the placeholder.invalid host that BaseUrlInterceptor rewrites to the live server with the auth cookie. The setCustomCacheKey(id) still keys the SimpleCache by trackId, so cache residency is unaffected by the URL form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:59:07 -04:00
bvandeusen 6967d62c09 fix(android): reconcile playlist cache to prune stale system-playlist UUIDs
Tapping certain playlists showed 'That playlist no longer exists' because the server's BuildSystemPlaylists rotates system-playlist UUIDs every rebuild, and the LIST refresh only upserted -- stale UUIDs lingered in the cache, tapping them triggered a 404. Mirrors playlists_provider.dart: PlaylistsRepository.refreshList now deletes the user's cached rows not in the fresh owned set (catches both deleted user playlists AND old system UUIDs since system playlists are user-scoped) before upserting the fresh all, atomically via a new replaceList @Transaction on the DAO. Also deletes the cached row when refreshDetail 404s so a stale tap self-heals the list. Inject AuthController for the current user id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:02:20 -04:00
bvandeusen 7bed0c226b refactor(android): migrate AlbumDetail TrackRow to shared TrackRow 2026-05-29 22:48:06 -04:00
bvandeusen 6582d7699e refactor(android): migrate History/Liked/Search/Playlist rows to shared TrackRow 2026-05-29 22:44:09 -04:00
bvandeusen bbd483603d refactor(android): add shared slot-based TrackRow 2026-05-29 21:40:38 -04:00
bvandeusen dfb7245db8 refactor(android): route remaining covers (TrackCoverThumb, mini, now-playing) through ServerImage 2026-05-29 21:36:41 -04:00
bvandeusen 3e35127284 refactor(android): use shared formatDuration + ms/sec conversions 2026-05-29 15:46:56 -04:00
bvandeusen cf0ccd1f90 refactor(android): route all cover getters through albumCoverPath 2026-05-29 15:33:32 -04:00
bvandeusen 0741ba31b3 refactor(android): add shared albumCoverPath cover-URL builder 2026-05-29 14:39:31 -04:00
bvandeusen 4015fb145d refactor(android): add shared formatDuration + ms/sec conversion helpers 2026-05-29 14:38:52 -04:00
bvandeusen d7dda2fcef fix(android): derive artist tile cover from first cached album
Library + Home artist tiles were blank: cached_artists stores no cover and the sync payload (SyncArtistWire) carries none. Mirror Flutter's artistTileProvider JOIN — ArtistRef gains coverAlbumId + a displayCoverUrl getter; LibraryRepository.observeArtists and MetadataProvider.observeArtist combine artists with albums to supply the first album (by sort title) as the cover source. ArtistCard renders displayCoverUrl through ServerImage. Updated LibraryRepositoryTest to stub albumDao.observeAll so combine emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:35:09 -04:00
bvandeusen 1b243347cb fix(android): resolve relative server cover URLs via shared ServerImage
The server returns relative cover_url (/api/albums/{id}/cover, /api/playlists/{id}/cover). Android only resolved the empty-fallback placeholder trick, so any non-empty relative URL from a fresh fetch went to Coil host-less and silently failed (album detail, artist-detail album grid, playlist cards, artist avatars). Add a shared ServerImage composable + resolveServerImageUrl that prefixes the placeholder host (rewritten by BaseUrlInterceptor) for relative paths, passes http(s) through, and falls back when blank. Route AlbumCard, PlaylistCard, AlbumDetail header, and ArtistAvatar through it. Mirrors Flutter's ServerImage._resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:30:21 -04:00
bvandeusen 85af93c63f fix(android): place shell banners below the status bar
ShellScaffold's banner column did not consume the status-bar inset while each screen's app bar did, so the update/connection banners drew under the status bar. Consume the inset once at the shell (statusBarsPadding) so banners sit below it; descendant screen app bars see the consumed inset and stop re-padding, matching Flutter's SafeArea(bottom:false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:11:19 -04:00
bvandeusen 01b05f37c3 feat(android): per-tile skeleton reveal on Home via reactive section flows 2026-05-28 19:42:09 -04:00
bvandeusen 4eb225914c feat(android): add HomeArtistPrewarmer (top-8 artists, session-deduped) 2026-05-28 18:58:29 -04:00
bvandeusen b2512fff4b feat(android): bound MetadataProvider on-miss fetches to 4 concurrent 2026-05-28 18:26:13 -04:00
bvandeusen 5b5bff767a feat(android): add HomeTile model for per-tile hydration 2026-05-28 18:12:57 -04:00
bvandeusen 68891f1df9 fix(android): hold previous NowPlaying gradient color across track change
rememberDominantColor was keyed on coverUrl, so each track change reset the extracted color to Transparent and the gradient dipped toward the fallback before tweening to the new color. Drop the key so the held color stays on the previous track's dominant until the new palette resolves -- the gradient now tweens directly to the new color. Combined with CoverPrefetcher warming the next cover, this matches Flutter's preload-then-atomic-swap UX (no flash on track change) via native Compose mechanisms rather than a literal preload pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:12:33 -04:00
bvandeusen 61cf07a3cf feat(android): soft "update available" shell banner (#25)
Ports Flutter's UpdateBanner. UpdateBannerController polls
/api/client/version at launch + every 24h, compares the bundled APK
against this build via isVersionNewer, and exposes the available
UpdateInfo (minus in-memory per-version dismissals). The shell banner
nudges "Update Minstrel · {version} available" with Install (reusing
ApkInstaller's download + system-install handoff, routing to the
install-permission settings first when needed) and a dismiss X. Uses an
understated surfaceVariant tone, distinct from the error-colored
VersionTooOldBanner hard gate.

Divergence noted: ApkInstaller exposes no byte progress, so the
downloading state shows an indeterminate bar rather than Flutter's
determinate one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:15:59 -04:00
bvandeusen 1ef5cd5b7a feat(android): swipe-up-to-expand gesture on MiniPlayer (#34)
Ports player_bar.dart's onVerticalDragEnd — an upward flick anywhere on
the bar expands into NowPlaying, alongside the existing tap-to-expand. A
vertical draggable claims only vertical-dominant drags, so the inline
seek slider keeps its horizontal scrub gesture. The 200 px/s Flutter
threshold is expressed in dp and density-converted so the flick feels
the same across screen densities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:33:43 -04:00
bvandeusen fbd6264037 feat(android): offline pools filter to cache-resident tracks (#38 slice 3)
ShuffleSource intersects the play-recency list with the Media3
SimpleCache key set, so the offline Recently-played / Liked pools only
offer tracks whose audio is actually on disk. Flutter's index holds
fully-cached tracks only; Android's records plays, so this intersection
restores parity — a played-then-evicted track no longer surfaces in a
pool where it would fail to play offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:17:12 -04:00
bvandeusen 5e18d506fe fix(android): correct Lucide icon name CircleCheckBig (#38 slice 2)
The composables lucide artifact names the property CircleCheckBig
(camelCase), not the snake-case filename — the prior import did not
resolve and broke compileDebugKotlin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:11:08 -04:00
bvandeusen 4713793900 feat(android): cached-indicator dot on track rows (#38 slice 2)
Ports Flutter's CachedIndicator (circle_check_big, size 14, accent, 4px
left pad) to all 5 track-row screens (Album, History, Liked, Playlist,
Search). CachedTrackIds exposes a reactive Set of trackIds with bytes in
Media3's SimpleCache — true on-disk residency (keyed per track via the
custom cache key from slice 1), so an evicted track loses its dot. Read
through a LocalCachedTrackIds CompositionLocal provided at the app root,
mirroring the existing LocalDetailSeedCache pattern, so leaf rows need no
per-screen plumbing. The persisted cache is read eagerly, so dots paint
for previously-cached tracks even when the library is opened offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:03:03 -04:00
bvandeusen 7024549e35 feat(android): populate audio_cache_index from playback (#38 slice 1)
CacheIndexer observes PlayerController.uiState and records each played
track into audio_cache_index (source=INCIDENTAL), bumping lastPlayedAt
on re-play. This gives the offline ShuffleSource pools their data
source — previously the index was never written, so the pools were
inert. MediaItems now set a custom cache key of trackId so Media3's
SimpleCache is keyed per track (sets up slice 2's residency queries).

Intentional divergence from Flutter's file-per-track index: SimpleCache
(span-based) is the real byte store, so the index is a lightweight
play-recency record. Slice 2 (#33) queries SimpleCache for true
residency + size + eviction-sync to drive the cached indicator dot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:47:10 -04:00
bvandeusen ffc2acc010 feat(android): Hidden tab cache-first via Room + Flow (SWR) — responsiveness
Closes #37. The Hidden tab was fetch-on-visit (spinner every open);
now it paints instantly from the cached_quarantine_mine Room table
and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController
(drift watch + transaction full-replace + optimistic flag/unflag).

The Room table + DAO already existed (CachedQuarantineEntity /
CachedQuarantineDao with observeAll + insert/delete) — they were just
never wired up. This commit connects them:
* DatabaseModule: provideCachedQuarantineDao (was missing from the
  graph — same gap as AudioCacheIndexDao).
* CachedQuarantineDao: atomic replaceAll(rows) (@Transaction
  clear+upsertAll, no flicker).
* QuarantineRepository: observeMine() Flow + refresh() full-replace;
  flag(track) / unflag(id) now mutate the cache optimistically (Flow
  re-emits instantly) then call the server, enqueueing on failure
  (intent persists, no rollback — matches Flutter). flag() takes a
  TrackRef to build the optimistic row; TrackActionsViewModel updated.
  listMine() kept for the TrackActions hidden-check.
* HiddenTabViewModel: uiState = combine(observeMine, refreshError),
  cache-first; SWR on init + on quarantine.* SSE; unflag via repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:14:28 -04:00
bvandeusen 4dc80047b2 feat(android): History tab cache-first via Room + Flow (SWR) — responsiveness
Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).

Native implementation (idiomatic, not a drift transliteration):
* CachedHistorySnapshotEntity — single-row table (id=1) holding the
  raw /api/me/history wire JSON + updatedAt. AppDatabase v5→6
  (fallbackToDestructiveMigration rebuilds; cache refills from server).
* CachedHistorySnapshotDao.observe() Flow + upsert; DatabaseModule
  @Provides bridge (the AudioCacheIndexDao lesson — every @Inject dep
  needs a provider).
* HistoryRepository.observeHistory(): Flow<HistoryPage?> decodes the
  blob; refresh() fetches + overwrites the snapshot. Whole page as one
  JSON blob (read-only timestamp-keyed snapshot — no per-row bookkeeping
  for no UX gain), matching Flutter's snapshot shape.
* HistoryTabViewModel: uiState = combine(observeHistory, refreshError)
  — cache-first, SWR, and Error only when there's no cache to show.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:33:29 -04:00
bvandeusen 14330e99da fix(android): provide AudioCacheIndexDao to Hilt graph
The offline-pool ShuffleSource (2a87a50b) injects AudioCacheIndexDao,
but DatabaseModule had no @Provides bridge for it — nothing had
injected that DAO via Hilt before, so the graph was missing the
binding and hiltJavaCompileDebug failed. Added the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:20:07 -04:00
bvandeusen 942d7e88cc docs: refine porting rule — exact on behavior+feel, idiomatic on implementation
Operator guidance: parity is about the user experience (layout, copy,
states, and especially responsiveness — caching exists to make the UI
feel instant), not literal transliteration of Flutter's Dart. Replicate
the behavior faithfully but reach it with the best well-supported native
mechanism (Room+Flow, Compose, WorkManager, Media3) rather than copying
drift watch() / Riverpod invalidate. A more native approach that improves
the UX is preferred — recorded as an intentional divergence in the parity
map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:23:29 -04:00
bvandeusen a37fe4c167 feat(android): ArtistDetail avatar falls back to first album cover (parity map)
Ports flutter_client/lib/library/artist_detail_screen.dart:177-208
_ArtistAvatar: server coverUrl wins; when empty, use the first loaded
album's /api/albums/{id}/cover; only show the bare Lucide.User icon
when the artist has neither. Threads detail.albums.firstOrNull()?.id
through ArtistHeader → ArtistAvatar. (Seeded-loading header passes
null since the seed carries no album list.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:21:58 -04:00
bvandeusen 851d5f92bb feat(android): HiddenTab live-refreshes on quarantine.* SSE (parity map)
Flutter's _LiveEventsDispatcher invalidates myQuarantineProvider on
any quarantine.* event, and the Hidden tab watches that provider — so
a hide/unhide/delete from another device updates the open Hidden tab
live, not just on re-open. Android's HiddenTab is fetch-on-visit with
no shared reactive cache, so per the Android LiveEventsDispatcher's
own documented pattern (screen-scoped state subscribes to EventsStream
directly), HiddenTabViewModel now collects quarantine.* and refreshes.
Same user-visible behavior as Flutter; same shape as the
TrackActionsViewModel SSE wiring.

(Paint-from-disk offline cache for the Hidden tab — Flutter's drift
cached_quarantine_mine — remains the separate deferred Phase-13 work.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:19:31 -04:00
bvandeusen 903a7961e3 feat(android): "Change server URL" link on Login (parity map: Auth)
Ports flutter_client/lib/auth/login_screen.dart:86-89 — a TextButton
under the Sign-in button that routes to the ServerUrl screen, so a
wrong server URL is recoverable pre-login without reinstalling or
signing out from Settings. Disabled while a submit is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:15:44 -04:00
bvandeusen f62e72b2f2 docs: add CLAUDE.md with the Flutter→Android porting discipline
Encodes the hard rule the operator asked for after repeated
divergence: when porting/changing any Android feature that exists in
Flutter, read the Flutter source FIRST and replicate it exactly;
never silently substitute a different design or scope down — verify
the perceived blocker by reading more, and raise it as a question if
genuinely blocked. Points at docs/superpowers/parity-map.md as the
durable feature→source→target→status reference (gitignored, local).
Also captures the standing repo conventions (Forgejo-only, dev→main
flow, no in-task builds, detekt gates) so they survive context
compaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:12:23 -04:00
bvandeusen 2a87a50b81 feat(android): offline-pool Home cards (audit v3 §4.3) — closes #28
Faithful port of Flutter's _OfflinePoolCard + shuffle_source.dart.
I was wrong earlier that Android lacked the source — the
audio_cache_index table already carries lastPlayedAt, exactly what
Flutter reads.

* ShuffleSource @Singleton over the local cache: recentlyPlayed()
  = audio_cache_index ordered by lastPlayedAt desc, materialized
  from cached_tracks; liked() = same ∩ the liked track-id set.
  Both are unions over the cache regardless of storage bucket,
  matching Flutter's note that the bucket split is eviction-only.
* OfflinePoolCard widget (sized to match PlaylistCard).
* AudioCacheIndexDao.trackIdsByRecency(); LikesRepository.likedTrackIds().
* HomeViewModel: offline StateFlow from ConnectivityObserver,
  playPool(kind) shuffles + plays (snackbar "No cached X tracks yet"
  when empty), transientMessages channel.
* buildPlaylistsRow gains an `offline` flag; when offline the two
  pool cards (Recently played, Liked) LEAD the Playlists row, before
  the system slots — the rest of the row (placeholders + user
  playlists) renders regardless, exactly as Flutter does.
* HomeScreen gains a SnackbarHost for the empty-pool message.

Together with edbc8053 (placeholders) this closes audit v3 #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:55 -04:00
bvandeusen edbc805333 feat(android): system-playlist placeholder cards on Home (audit v3 §4.4, part of #28)
The Home Playlists row now always renders the For You + Discover +
3× Songs-like slots: a real PlaylistCard when the system playlist
has generated, otherwise a PlaylistPlaceholderCard showing its
build state — building / failed / seed-needed / pending — derived
from GET /api/me/system-playlists-status. Mirrors Flutter's
_buildPlaylistsRow + PlaylistPlaceholderCard.

* SystemPlaylistsStatus domain + wire; MeApi.getSystemPlaylistsStatus;
  HomeRepository.getSystemPlaylistsStatus.
* HomeViewModel fetches the status in refresh() and exposes it as a
  StateFlow; HomeScreen collects it and threads it to the row builder.
* PlaylistPlaceholderCard widget (sized to match PlaylistCard) with
  a status chip + variant subtitle.
* buildPlaylistsRow / variantFor port the slot logic; user playlists
  follow the fixed system slots.

The offline-pool cards (§4.3) are NOT in this commit — Android has
no local recently-played cache (history is fetch-on-visit), so the
"Recently played" pool has no offline source. Deferring that half
until the history snapshot cache lands; see #28 notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:42:13 -04:00
bvandeusen 8f5635c489 feat(android): in-app APK update install (audit v3 #25)
Wires the "Install vX.Y.Z" action the About card's update-check
teed up.

* ApkInstaller @Singleton — downloads the server APK via the shared
  OkHttpClient (inherits auth cookie + BaseUrlInterceptor host
  rewrite; apkUrl is server-relative) into cacheDir, then launches
  the system installer via a FileProvider content:// URI. canInstall()
  gates on PackageManager.canRequestPackageInstalls() on O+, and
  requestInstallPermission() opens the "install unknown apps" settings
  page when not yet granted.
* Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
  (${applicationId}.fileprovider) + res/xml/file_paths.xml exposing
  the cache dir.
* AboutCardViewModel.install(info): permission check → download →
  launch, with isInstalling + installMessage state. Errors routed
  through ErrorCopy.
* About card shows an "Install vX.Y.Z" button under the check button
  when an update is available, "Downloading…" while in flight, and
  the install message line. Extracted UpdateControls / InstallButton /
  ButtonSpinner helpers to keep AboutCard under the length cap;
  added @file:Suppress(TooManyFunctions) for the settings-card density.

Closes audit v3 #25 — the last open parity item from the v3 sweep
aside from the offline-pool Home cards (#28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:18:49 -04:00
bvandeusen 5ce9ca9e62 fix(android): NowPlaying go-to-album/artist navigates atomically (audit v3 Bug-5)
The kebab "Go to album/artist" did popBackStack() then navigate() —
two transactions, leaving a visible frame on whichever shell tab sat
under NowPlaying before the detail pushed. Replaced with a single
navigate(...) { popUpTo(NowPlaying) { inclusive = true } } so the
modal is removed and the detail pushed in one atomic transaction,
no intermediate frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:09:33 -04:00