Commit Graph

66 Commits

Author SHA1 Message Date
bvandeusen a9e277eb13 test(flutter): fix 6 latent failures uncovered by the drift cohort un-skip
All six tests had been silently skipped under @Tags(['drift']) for
6+ months, so they accumulated test-vs-implementation drift. Now
running against the libsqlite3-bearing ci-flutter:3.44 image, each
failed for a distinct reason. Diagnoses below.

audio_cache_manager_test 'usageBytes sums sizeBytes across rows':
  usageBytes() is a directory walk (authoritative on-disk total,
  catches orphan partials the index misses). The test inserted drift
  rows but never wrote files, so the walk returned 0. The actual API
  for summing drift sizeBytes is bucketUsage(). Rename to
  'bucketUsage sums drift sizeBytes across rows', use that API,
  assert liked+rolling == 350. Also give the two rows unique paths
  per row-shape sanity.

sync_controller_test (3 200-path tests, all returning null result):
  Map literals in Dart 3 with mixed value types infer as
  Map<String, Object>, not Map<String, dynamic>. The sync controller
  casts `resp.data as Map<String, dynamic>` (and several nested
  casts), which is invariant on generics and throws TypeError. The
  silent try/catch in sync() swallowed the throw and returned null.
  Real JSON parsing produces Map<String, dynamic>, so this never
  surfaced in production. Fix: route the test stub body through
  jsonDecode(jsonEncode(body)) in _stubDio — mimics real Dio's
  parsed-response shape. Affects '200 with artist upsert', '200 with
  track delete', and 'like_track upsert + delete round-trip'.

quarantine_provider_test 'flag keeps drift optimistic + queues
mutation on server failure':
  When the API stub throws, the controller catches + queues to
  CachedMutations. The drift watch() stream in MyQuarantineController
  was still in loading state when addTearDown disposed the
  container, tripping Riverpod's "StreamProvider disposed during
  loading" assertion. The success-path tests resolved before
  tearDown so they didn't see it. Fix: await one microtask before
  the test ends so the stream emits.

like_button_test 'tap toggles icon optimistically; rollback on error':
  After the LikeButton was migrated to LucideHeart in the Lucide
  sweep, the prior fix replaced the find.byIcon assertion with a
  heartFilled() helper reading LucideHeart.filled. But likesController
  .toggle() goes through an async chain (optimistic state flip + await
  api.like + state notification), which one frame of tester.pump()
  doesn't flush. Use pumpAndSettle after both tap and rollback toggle
  so the widget rebuilds with the new state before the assertion.

After this push, the drift cohort should be all-green on the
libsqlite3-bearing image. Fable #399 / local #62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:52:05 -04:00
bvandeusen 8fb6b4fbb3 test(flutter): fix two latent failures exposed by un-skipping the drift cohort
Both fixes paired with the ci-flutter:3.44 rebuild that adds
libsqlite3-dev to the runner image (CI-runner push #2: libsqlite3-0
→ -dev because dart:ffi opens the unversioned .so symlink that only
the dev package ships).

widgets_smoke_test (TrackRow):
  TrackRow contains CachedIndicator which reaches
  audioCacheManagerProvider → appDbProvider → drift_flutter's
  `driftDatabase()`. That schedules a deferred-init Timer that
  outlives the test widget tree and trips the
  "A Timer is still pending after dispose" invariant. Override
  appDbProvider in the test to use AppDb(NativeDatabase.memory())
  directly — bypasses drift_flutter's Timer-using init path, still
  exercises real SQLite via FFI.

like_button_test (tap toggles + rollback):
  LikeButton was migrated to LucideHeart (SVG widget with `filled`
  bool) in the Lucide sweep; the test's `find.byIcon(Icons.favorite)`
  is stale. Replace with a small heartFilled() helper that reads
  the LucideHeart's `filled` prop straight off the widget tree.
  Same assertion semantics, just against the post-migration shape.

The four sync_controller failures need no code change — they're
the same root cause as the drift-tagged cohort (libsqlite3.so
missing → try/catch returns null → `result?.upserts` is null
instead of the expected 0). The image fix should clear them.

Fable #399 / local #62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:15:18 -04:00
bvandeusen 85183c455a test(flutter): re-enable drift tests after ci-flutter:1.26 ships libsqlite3-0
Drops the libsqlite3-missing skip cohort now that the ci-flutter
runner image installs libsqlite3-0 (CI-runner commit on its main).

Per-file removals (no behavior change in tests themselves — they
just stop being skipped):
- `@Tags(['drift'])` + `library;` directive from 5 files.
- `const _skipDrift = ...;` declaration + its rationale comment
  from 6 files (the 5 above + like_button_test.dart, which had its
  own _skipDrift for the rollback-via-drift case).
- `skip: _skipDrift` annotations from 17 test invocations across
  those 6 files (16 single-line + 1 multi-line in like_button).
- Stale `@Tags(['drift']) tier covers it` reference in
  home_screen_test.dart's drift-coverage comment.

Net -79 +18 lines across 7 files; 17 previously-silent tests are
now part of the CI signal. Fable #399.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:02:17 -04:00
bvandeusen 359a072937 test(playlists): playlist_card finds LucideIcons.ellipsis_vertical
#60 swapped Icons.more_vert -> LucideIcons.ellipsis_vertical in
playlist_card.dart; the widget test still asserted the old Material
icon (find.byIcon(Icons.more_vert)) and failed. Update both finders
+ import flutter_lucide.

Note: like_button_test.dart still references Icons.favorite but is
skip:true (gated on Fable #399) so it compiles and doesn't run;
flagged as stale to update when that test is unskipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:10 -04:00
bvandeusen a5e2abb8c4 feat(offline): #427 S4a — Shuffle all + offline system-playlist gate
Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online  → GET /api/library/shuffle?limit=N (new): N random
  library tracks server-side, per-user quarantine filtered
  (ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
  (audio_cache_index ∩ cached_tracks, names from cached_artists/
  albums) — a UNION over liked AND recently-played, since the
  two-bucket split is storage-only and never filters playback.

Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.

playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).

S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:37:29 -04:00
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00
bvandeusen 7a0437087a feat(flutter): refresh kebab on system playlist cards (#416 parity)
Closes the Flutter half of Fable #416. Web got a generalized
system-playlist refresh kebab in d12afda; this brings Flutter to
parity instead of leaving the affordance web-only.

- PlaylistsApi.refreshSystem(variant): POST
  /api/playlists/system/{for-you|discover}/refresh, maps the
  underscore model variant to the hyphenated route segment,
  returns the rotated playlist id.
- PlaylistCard: top-right PopupMenuButton on system playlists
  with a context-labelled "Refresh For You" / "Refresh Discover"
  item. Calls refreshSystem, invalidates playlistsListProvider
  (which reconciles the rotated UUID + new tracks), snackbars
  the result. ScaffoldMessenger captured pre-await.
- Tests: kebab present for system, absent for user playlists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:35:55 -04:00
bvandeusen 335940cf23 feat(cache): outbound mutation queue for offline-resilient REST
User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.

**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
  / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
  permanently-failing mutation eventually drops, and next sync
  reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
  postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
  quarantine.unflag / playlist.append / request.create /
  request.cancel — each with a Ref+payload handler that re-fires
  the corresponding REST call.

**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
  across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
  visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
  cached_playlist_tracks write (position = max + 1) so the
  playlist detail screen shows the new track instantly. Queues
  appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
  No drift state for the request itself (myRequestsProvider is
  still REST-only) so the row won't show on /requests until replay
  succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
  longer restores on failure; queues request.cancel instead.

**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.

**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
  drift table)
* UI "syncing N pending changes" indicator (operator preference:
  silent unless we find a concrete need)
2026-05-14 18:27:05 -04:00
bvandeusen 67bacac84b fix(test): capture cacheFirst stream Future before feeding controller 2026-05-14 15:59:16 -04:00
bvandeusen e38189470b test(cache_first): update test for new yield-after-fetch semantics
The cacheFirst fix in 5511f87 added a yield after fetchAndPopulate
so streams never hang when populate is a no-op for this filter
(the liked-tab spinner-forever bug). Test expectation updated: the
first emission after an empty drift is now the still-empty yield
("we tried, nothing to show yet"), and the simulated drift re-emit
yields the populated rows as the second emission.
2026-05-14 15:41:00 -04:00
bvandeusen 2a18e91c39 feat(flutter): drift-first Library tabs + systemPlaylistsStatus + tab pre-warm
Four-part change to push more surfaces onto the drift cache and
eliminate cold-tab-visit latency on the Library screen.

* **Library Artists tab** — _libraryArtistsProvider migrates from
  REST-paginated AsyncNotifier with infinite-scroll loadMore to a
  drift-first StreamProvider over cached_artists ordered by
  sortName. Sync already populates the full set; cacheFirst's
  fetchAndPopulate covers the fresh-install + sync-not-yet-done
  cold case via /api/artists?limit=1000. SWR refresh on every
  visit. GridView.builder lazily realizes only visible cells so
  loading the full list up front is fine for typical libraries.
  loadMore + NotificationListener gone.

* **Library Albums tab** — same migration, drift-first over
  cached_albums joined with cached_artists for the artistName
  field.

* **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus
  single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot)
  for the home Playlists row's "building / pending / failed"
  placeholder logic. Drift-first means the row paints with the
  prior status instantly instead of flickering through
  SystemPlaylistsStatus.empty() while the REST call resolves.

* **Library screen tab pre-warm** — ref.listen on all 5 tab
  providers in _LibraryScreenState.build subscribes them upfront
  so swiping between tabs feels instant rather than each tab
  paying its own cold-cache cost on first visit. cacheFirst
  handles dedupe of concurrent fetchAndPopulate triggers.

Test mock updated for the StreamProvider type change on
systemPlaylistsStatusProvider.
2026-05-14 10:14:46 -04:00
bvandeusen 03c13d21c6 feat(flutter): home screen on per-item rendering (Slice C)
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.

Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.

The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
  smaller than /api/home), then sections appear shaped with
  skeleton tiles; each tile materializes as the hydration queue
  drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
  instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
  real cards next to skeleton cards. Layout doesn't shift because
  skeletons match real-card dimensions exactly.

CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.

Old /api/home endpoint untouched, so the web client keeps working
unchanged.
2026-05-13 21:05:43 -04:00
bvandeusen 99462185b4 feat(flutter): drift-first MyQuarantine
Final slice of the smooth-loading pass. Adds CachedQuarantineMine
(schema 5, columnar so flag/unflag can do row-level mutation) and
rewires MyQuarantineController to read from drift via watch() + SWR
refresh; flag/unflag write drift first and roll back on REST failure.

Public API (.flag / .unflag / .isHidden) unchanged so existing call
sites (library_screen Hidden tab, TrackActionsSheet) keep working.

Tests updated to match: bypassed-build-via-_StubController approach
no longer makes sense now that state lives in drift, so the suite is
rewritten against NativeDatabase.memory() with the same libsqlite3
skip the sync_controller suite uses on the CI runner.

The Hidden tab now paints from disk on cold open, the list is
queryable offline, and a flag from another device that arrived in
this user's quarantine via SSE-triggered invalidate lands the same
way as a local flag.
2026-05-13 18:41:18 -04:00
bvandeusen 22bd06a578 feat(flutter): drift-first homeProvider (#357 deferred follow-up)
Home screen is the first surface on app open; without a local cache
the cold-start blocks on /api/home, which dominates felt latency on
slow or remote connections. This commit caches the last successful
HomeData as a single-row JSON blob in drift, so subsequent app opens
yield content immediately and revalidate in the background.

Schema:
  - New CachedHomeSnapshot table (single row: id=1, json TEXT,
    updated_at). schemaVersion bumped 2 → 3 with a forward migration
    that calls m.createTable(cachedHomeSnapshot). Codegen regenerated
    via build_runner; the *.g.dart files are gitignored and rebuilt
    by the CI Codegen step.

Provider rewrite:
  - homeProvider: FutureProvider<HomeData> → StreamProvider<HomeData>
    using the existing cacheFirst<CachedHomeSnapshotData, HomeData>
    pattern (alwaysRefresh: true for SWR). On cold cache the first
    /api/home fetch populates the row. On warm cache the cached
    HomeData is yielded immediately and a background REST fetch
    overwrites the row, which drift's watch() picks up.

  - Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so
    HomeData survives the JSON round-trip into and out of drift.
    Field names match the server's /api/home wire shape exactly so
    HomeData.fromJson handles both fresh server responses and cached
    drift rows.

Callers untouched: home_screen.dart's ref.watch + ref.refresh +
metadata_prefetcher's ref.listen all keep working with the
StreamProvider shape (AsyncValue<HomeData> stays the surface type).

Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart
switched from `(ref) async => _emptyHome` (FutureProvider form) to
`(ref) => Stream.value(_emptyHome)` (StreamProvider form).

For #357. Completes the user-visible deferred follow-up. Remaining
deferred items: library_changes server-side retention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:40:43 -04:00
bvandeusen 74cc76b369 fix(flutter): preserve dev-version safety net + update version tests
Two test failures from the comparator rewrite:

1. "different unparseable strings → newer" was useful — branch-name
   builds (e.g. PackageInfo reports 'main' while server reports
   'dev') should still surface the banner so the operator sees that
   something is misaligned. Restore that fallback: if both sides
   parse to all-zero components (no numeric structure on either),
   compare as strings.

2. "honors prerelease ordering" tested pub_semver behavior we no
   longer use. Replaced with tests that cover what the new
   comparator actually does: zero-padding for length mismatch,
   leading-zero normalization, 4-part date+build comparisons, and
   month-rollover correctness without leading zeros.
2026-05-11 10:48:06 -04:00
bvandeusen f6e27cd39a fix(flutter/test): isVersionNewer date-style assertion was wrong
pub_semver is permissive with leading zeros — '2026.05.10' parses as
2026.5.10, so date-style tags get strict semver ordering, not the
unparseable-fallback path. The test was asserting "any difference =
newer" for date strings, which is incorrect; the actual behavior
(and the right behavior) is proper ordering.

Split the test group into two:
- date-style (parses as semver): newer/older/equal assertions
- truly unparseable (e.g. branch names like 'main', 'dev'): the
  fallback's "any difference = newer" semantics still apply

Implementation in client_update_provider.dart unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:53 -04:00
bvandeusen 02d9f39845 feat(flutter): in-app update banner + Android install intent (#397 phase 2)
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.

### Dart side

- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
  polling on app start + every 24h. Compares semver via pub_semver
  with non-semver string-equality fallback. Server 404 = "no update
  channel" = silent. Companion shouldShowUpdateBannerProvider gates
  on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
  the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
  downloading (with progress) / error stages. Mounted in
  _ShellWithPlayerBar above the route content; SizedBox.shrink()
  when no update available so non-shell routes (login, server-url)
  see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
  semver, prerelease ordering, leading-v normalization, and
  non-semver fallback (date-tag style).

### Android side

- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
  FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
  downloaded APK can be served via content:// URI (file:// is
  blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
  URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
  so the system installer can read across the process boundary.

Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:42:04 -04:00
bvandeusen 0db7054518 feat(flutter): user-facing requests view + cancel (#356)
Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.

- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
  cancel(id). Same /api/requests endpoint the web /requests page
  uses; identical AdminRequest wire shape so the existing model
  is reused (just augmented with matched_*_id fields for the
  "Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
  already shipped server-side / web-side): 12s refresh while any
  row is mid-ingest (status='approved'), stops on settle. Riverpod
  Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
  Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
  Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
  the Cancel-confirm dialog.

Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:08:23 -04:00
bvandeusen 94727f40bb fix(flutter): analyze errors after Plan C provider migrations
- cache_first.dart: backtick-fence List<T> doc comment to dodge
  unintended_html_in_doc_comment.
- library_providers.dart:163: switch single-row insert to
  insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict*
  variant.
- 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>,
  not Create<Future<T>>. Wrap data emissions in Stream.value(...).
- artist_detail_screen_test: add models/album.dart import for AlbumRef
  type annotation.
- track_actions_sheet_test: drop _StubLiked extends LikedIdsController
  (notifier no longer exists post-migration); override with
  Stream.value(LikedIds(...)) instead.
- like_button_test: rollback assertion now requires drift writes via
  LikesController. Skip under _skipDrift until libsqlite3-dev lands on
  the runner image (Fable #399). Replace stale .notifier reference
  with likesControllerProvider for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:27 -04:00
bvandeusen 4eb9935f8e feat(flutter/cache): cacheFirst helper + drift↔model adapters (#357 plan C)
Foundation for the provider migrations. cacheFirst<D, T> wraps the
drift.watch() + REST cold-cache fallback pattern: yields cached rows
when present, kicks off REST fetch + drift populate when empty +
online, yields empty when offline. REST failures swallow to empty so
callers can surface errors via toast.

adapters.dart adds CachedX → XRef extension methods + reverse
XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters
accept some loss of server-derived fields (coverUrl, streamUrl,
ownerUsername) — UI already handles empty values; cold-cache fallback
briefly shows the real values before drift takes over.

cache_first_test covers all 4 branches (non-empty, empty+online,
empty+offline, REST failure). adapters_test covers basic round-trips.
Both safe to run on CI runner — no drift open required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:29 -04:00
bvandeusen 2940e95b64 fix(flutter/test): skip TrackRow smoke test under the drift cohort
Pending-timer failure: CachedIndicator (now in TrackRow) reads
audioCacheManagerProvider, which constructs AppDb via drift_flutter's
driftDatabase(), which calls libsqlite3 — missing on the flutter-ci
runner. The async chain leaves a pending timer at test teardown.

Skip cohort matches sync_controller / audio_cache_manager / storage_section
tests; all re-enable once Fable #399 lands the runner image fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:49:03 -04:00
bvandeusen a3db6c9453 fix(flutter/test): testWidgets skip takes bool, not String
CI analyze flagged storage_section_test.dart's `skip: _skipDrift` calls.
testWidgets has signature `skip: bool?`; only test() takes `String?` as
the skip-reason. Changed _skipDrift in this file to `const bool = true`
and moved the rationale into a comment. The other two drift-cohort
files (sync_controller_test, audio_cache_manager_test) use test() so
their String const is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:41:48 -04:00
bvandeusen 1b223d3891 fix(flutter/test): skip drift tests pending libsqlite3 in CI runner image
CI's flutter-ci runner doesn't ship libsqlite3.so. drift's NativeDatabase
fails at first use ("Failed to load dynamic library 'libsqlite3.so'").
Affected files:
  - test/cache/sync_controller_test.dart (4 tests)
  - test/cache/audio_cache_manager_test.dart (5 tests)
  - test/settings/storage_section_test.dart (2 tests, was silently
    succeeding because the drift call was best-effort but emitted
    multiple-AppDb warnings)

All 11 marked with skip: '...' + a top-level @Tags(['drift']) library
declaration so they can be re-enabled by tag once the runner image has
libsqlite3-dev installed (or once we move VM tests to sqlite3/wasm).
On-device verification covers the actual cache + sync logic.

Plus two collateral fixes:
  - test/cache/connectivity_provider_test.dart: connectivity_plus needs
    a platform channel that doesn't exist in unit tests; reduced to a
    non-null import smoke check.
  - test/library/widgets_smoke_test.dart: TrackRow now contains
    CachedIndicator (ConsumerWidget); wrapped TrackRow test in
    ProviderScope.

Filing follow-up for the runner image fix in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:27:49 -04:00
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00
bvandeusen fd4ce402d7 feat(flutter/settings): Storage section UI — usage, cap, prefetch, controls
New StorageSection widget mounted in SettingsScreen between Appearance
and Password sections. Surfaces:

  - Cache usage display (live, refreshed on cap change + on Clear)
  - Cache size limit dropdown (1 / 5 / 10 / 25 GB / Unlimited)
  - Pre-fetch ahead dropdown (1 / 3 / 5 / 7 / 10 tracks)
  - Cache liked tracks switch
  - Clear cache button (confirm dialog, then clearAll)
  - Sync now button (triggers SyncController.sync(), spinner while inflight)

Cap change triggers immediate eviction. Clear cache wipes EVERYTHING
(manual-pinned tracks too) per the operator-facing copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:34 -04:00
bvandeusen 536350380f feat(flutter/cache): Prefetcher — queue-ahead pre-download window
Listens to mediaItemProvider + cacheSettingsProvider. On track change
(or settings change), computes the [currentIdx, currentIdx + N] window
in the queue and pins each uncached track via AudioCacheManager with
source: autoPrefetch.

Window N comes from cacheSettingsProvider.prefetchWindow (default 5,
configurable in Settings → Storage card).

Smoke-test only at the unit level — real coverage via on-device pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:19:01 -04:00
bvandeusen ab58f3ffcb feat(flutter/cache): SyncController — token-based delta sync
Drives /api/library/sync against drift:
  - reads cursor from SyncMetadata (default 0 = full snapshot)
  - 204 → just bump lastSyncAt, return zeroes
  - 410 → wipe all cached entities + retry from cursor=0
  - 200 → apply upserts + deletes per entity type, advance cursor

Handles all 8 entity types (artist/album/track + like_track/like_album/
like_artist + playlist/playlist_track) for both upsert and delete paths.
Composite-key entities use the "<a>:<b>" string format the server emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:18:15 -04:00
bvandeusen 16669a0365 feat(flutter/cache): AudioCacheManager — pin/unpin/evict + tiered LRU
API:
  - isCached(trackId), pathFor(trackId)
  - pin(trackId, source) — dio.download → drift index entry
  - unpin(trackId), clearAll()
  - usageBytes(), evict(targetBytes)

Eviction order: incidental → autoPrefetch → autoPlaylist → autoLiked.
'manual' never evicts via cap-driven eviction; only clearAll() removes
operator-pinned tracks.

Two providers:
  - appDbProvider — singleton AppDb per run
  - audioCacheManagerProvider — wraps the AppDb + dioProvider

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:17:14 -04:00
bvandeusen 2bcbb53c49 feat(flutter/cache): connectivityProvider + cacheSettingsProvider
connectivityProvider — StreamProvider<bool>; true when any connectivity
result is non-none. No Wi-Fi gate per operator decision.

cacheSettingsProvider — AsyncNotifier<CacheSettings> persisting
capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage.
Defaults: 5 GB cap, 5-track prefetch, cache liked = on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:15:17 -04:00
bvandeusen 84a638a41f fix(flutter): clear analyze --fatal-infos issues
Three classes:

1. album_cover_cache.dart docstring used <applicationCacheDirectory>
   and <albumId> which the analyzer reads as unintended HTML. Wrapped
   the path in backticks and used {} placeholders.

2. settings AppearanceSection used RadioListTile.groupValue + onChanged,
   deprecated in Flutter 3.32+. Wrapped the radios in a RadioGroup
   ancestor (the new API) so individual tiles only declare value +
   activeColor. Test updated to read groupValue off the RadioGroup
   ancestor.

3. theme_extension.dart factories built FabledSwordTheme(...) without
   const, even though all args are static const tokens. Added const to
   the outer constructor calls in both .dark() and .light() — this
   propagates const to the inner TextStyle(...) calls automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:16:35 -04:00
bvandeusen 231f1ddf9a test(flutter/theme): cover .dark()/.light() factories + back-compat alias
Four cases: dark factory pulls from FabledSwordDarkTokens, light from
FabledSwordLightTokens, flat tokens (accent / moss / etc.) are shared
between both, and the fromTokens() back-compat alias returns the dark
theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:51 -04:00
bvandeusen 143130eaae feat(flutter/settings): Appearance section with System/Light/Dark picker
Three RadioListTiles between Profile and Password sections. Tap →
themeModeProvider.notifier.set(...) → MaterialApp rebuilds with the
new theme via the existing themeMode wiring.

System has the only subtitle ("Match the device setting") — the
light/dark options are self-explanatory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:29 -04:00
bvandeusen 6dc488652e feat(flutter/theme): themeModeProvider with secure-storage persistence
AsyncNotifier wrapping flutter_secure_storage's "theme_mode" key.
Returns AppThemeMode.system as the default. set(mode) writes the
value + updates state.

Used by MinstrelApp (next commit) to drive MaterialApp.themeMode and
by the Settings appearance section to persist the operator's pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:00 -04:00
bvandeusen 36006d703d feat(flutter/track-actions): AddToPlaylistSheet picker
Modal bottom sheet listing the caller's user-created playlists (system
playlists like For-You are filtered out — they're not user-mutable).
Tap a row to pop with the playlist id; consumer (TrackActionsSheet)
then calls PlaylistsApi.appendTracks.

Empty-state copy: "You haven't created any playlists yet."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:42:19 -04:00
bvandeusen 2944288050 feat(flutter/track-actions): HideTrackSheet for reason + notes pick
Modal bottom sheet with five reason chips (mirroring server vocabulary
bad_rip / wrong_file / wrong_tags / duplicate / other) plus an optional
notes field. Returns (reason, notes) on Hide, null on Cancel. Default
selection is bad_rip.

Used by TrackActionsSheet's Hide action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:41:48 -04:00
bvandeusen 5f239f05a5 feat(flutter/track-actions): TrackActionsButton + TrackActionsSheet
The 3-dot trigger and the modal-bottom-sheet menu it opens. 7 actions:
Play next, Add to queue, Like/Unlike, Add to playlist, Go to album,
Go to artist, Hide/Unhide. hideQueueActions: true suppresses the
first two for the Now Playing surface.

Sub-sheets (HideTrackSheet, AddToPlaylistSheet) land in subsequent
commits — this commit imports them speculatively, so CI between this
and the next two commits will fail. Resolved by Tasks 5 + 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:41:16 -04:00
bvandeusen 5159bcd3f4 feat(flutter/quarantine): MyQuarantineController + provider
AsyncNotifier wrapping /api/quarantine/mine with optimistic flag /
unflag mutations and an isHidden(trackId) convenience for menu state.
Mirrors the LikedIdsController pattern: optimistic update, rollback
on server error.

Used by the next slice's TrackActionsSheet to power Hide / Unhide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 -04:00
bvandeusen b6d6f22598 feat(flutter/player): AlbumCoverCache for lock-screen artwork
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.

Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.

No eviction — covers are tiny, OS clears cache when space is tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:20 -04:00
bvandeusen d3a05520d3 fix(flutter): clear analyze --fatal-infos issues
Three info-level lints from CI:

- playlist_card.dart doc comment: "/playlists/<id>" → "/playlists/{id}"
  in backticks, since `<id>` was flagged as unintended HTML.
- compact_track_card_test.dart: home: Scaffold(...) is now const, which
  propagates const to CompactTrackCard and makes the inner [_track]
  implicitly const.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:05:02 -04:00
bvandeusen 17ff69780d test(flutter/home): cover new home parity layout
Expands the existing single test into four cases:
- 4 placeholder cards in Playlists row when no system playlists exist
- Empty-state copy for each section verbatim against the strings
  in HomeScreen
- Real For-You card renders with the system badge when present
- Existing rollups (Recently added / Rediscover) still render correctly
  alongside the new providers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:40:46 -04:00
bvandeusen a7fcafadda feat(flutter): DelayedLoading wrapper for skeleton-with-delay
Renders nothing for the first [delay] (default 200ms) of sustained
loading, then renders the [whileDelayed] child until loading completes.
Mirrors the web useDelayed hook so a fast network response doesn't
flash a skeleton.

Used in the next commit's home screen rewrite; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:42 -04:00
bvandeusen 3ac8cc9363 feat(flutter/widgets): PlaylistCard + PlaylistPlaceholderCard
Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.

PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"

Used in the next commit's home Playlists row; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:19 -04:00
bvandeusen dccdb00bce feat(flutter/widgets): CompactTrackCard for dense home Most-played row
Mirrors the web CompactTrackCard — small horizontal cell with cover
art (48dp), title, artist. Tap plays from this track in the section.
Width 176dp matches web's compact density so 25 tracks per row scroll
naturally on phone widths.

Cover URL is derived from track.albumId (/api/albums/<id>/cover) since
TrackRef itself doesn't carry a cover field — same pattern as web's
coverUrl() helper.

Used in the next commit's home Most-played section (3 rows × 25);
otherwise unused this slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:37:40 -04:00
bvandeusen 9f6ceb1731 feat(flutter/admin): users screen with users + invites sections
Single-scroll layout with two _SectionHeader-anchored blocks. Users
rows tap into AdminUserEditSheet. Invites row shows token (monospace)
with copy button, optional note, and a redeemed badge if applicable.

"Generate invite" opens a small dialog with an optional note field
(server hardcodes 24h TTL — admins can't customise expiry). On
success the new token is shown in a copy-once result dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:53 -04:00
bvandeusen 3e905db906 feat(flutter/admin): quarantine screen + typed-confirm sheet
ExpansionTile per aggregated quarantine row — collapsed shows track,
artist · album, and "{N} reports: {top reason}". Expanded reveals the
nested per-user reports with their reasons + optional notes.

Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both
deletes route through TypedConfirmSheet which requires the user to
type "DELETE" before the action button enables.

Adds AdminQuarantineController with optimistic-removal + rollback,
mirroring AdminRequestsController's pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:56:00 -04:00
bvandeusen bd1bcab12b feat(flutter/admin): requests screen with approve/reject
AdminRequestsScreen renders rows from adminRequestsProvider, joining
requester user_id → username via adminUsersProvider for display
(falls back to UUID prefix when the users list hasn't loaded yet).
Approve fires immediately; Reject confirms via dialog. Both are
optimistic with rollback in the controller.

Adds AdminUsersController build-only (the read side) so the row join
works; mutation methods land in Slice 3 alongside the Users screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:54:58 -04:00
bvandeusen 6b5d12f3a1 feat(flutter/admin): landing screen with section cards + counts
ListView of three AdminSectionCard tiles (Requests / Quarantine /
Users) showing live counts from adminCountsProvider. Pull-to-refresh
re-fans-out to the three list endpoints. Admin/Quarantine/Users
sub-routes will land in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:53:03 -04:00
bvandeusen 6564d37a2a feat(flutter): MainAppBarActions widget with admin-gated overflow
Shared AppBar actions for top-level screens — three primary icons
(Home/Library/Search, current screen suppressed) plus a kebab containing
Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the
ad-hoc per-screen IconButton lists; centralises admin-gating logic in
one place so /admin entry can't accidentally leak to non-admins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:48:52 -04:00
bvandeusen 1ad67dbe59 fix(flutter/test): tolerate Riverpod 3's ProviderException wrapper 2026-05-08 14:43:31 -04:00
bvandeusen 66545bf8c3 feat(flutter): library screen with 5 tabs (Artists/Albums/History/Liked/Hidden)
Models:
- history_event.dart (HistoryEvent + HistoryPage envelope)
- quarantine_mine.dart (QuarantineMineRow for /api/quarantine/mine)
- Renamed Page<T> -> Paged<T> to avoid collision with Material's Page

Endpoints:
- library_lists.dart: listArtists / listAlbums (paged)
- me.dart: history + quarantineMine
- likes.dart: extended with listTracks/Albums/Artists (paged)

UI:
- library_screen.dart: TabBar over 5 tabs
- Artists tab: 3-col grid
- Albums tab: 2-col grid
- History tab: list with relative-time formatting (matches HistoryRow.svelte)
- Liked tab: 3 stacked sections (artists scroll-row, albums scroll-row, tracks list)
- Hidden tab: list of quarantined tracks with reason pill
- /library route + library_music icon on home AppBar

First page only for v1 (limit 50). Infinite scroll deferred.
2026-05-08 14:40:26 -04:00