16 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 1e8b8aaa2d fix(flutter): drop redundant const in nested const contexts
Making the outer FabledSwordTheme(...) and HomeData(...) constructors
const made every inner const redundant — Dart's analyzer fires
unnecessary_const for each. Strip the inner consts so the outer
context owns the const inference.
2026-05-02 19:17:05 -04:00
bvandeusen d86c694cde fix(flutter): five flutter analyze --fatal-infos issues from first CI run
1. lib/app.dart + lib/shared/routing.dart — buildRouter takes a Ref but was
   being called from a ConsumerWidget's build() with a WidgetRef. Add a
   routerProvider; the widget watches it instead of constructing the
   router from its own ref. Real bug — would have crashed compile in
   slice 1, just never compiled until CI ran.

2. lib/player/audio_handler.dart — override of AudioHandler.seek used
   `p` for the Duration; rename to `position` to match the base class
   (avoid_renaming_method_parameters lint).

3. lib/theme/theme_extension.dart — fromTokens() returned a non-const
   constructor; all inputs are const so make the call const too
   (prefer_const_constructors).

4. test/library/home_screen_test.dart — same const-constructor lint on
   the HomeData test fixture.

5. test/smoke_test.dart — drop the unused
   `import 'package:flutter/material.dart'`.

These are exactly the kind of issues the no-in-task-tests rule shifted
to CI; this is the first run that actually exercises the analyzer
against the slice 1 code.
2026-05-02 19:01:54 -04:00
bvandeusen 6ec7cea362 feat(flutter/library): artist + album detail screens
Headers carry placeholder play buttons (wired in Task 18) + room for
LikeButton (Task 16). Artist screen has a 2-column album grid; album
screen has a track list with mm:ss durations. Routes :id-parameterized
under the shell so the PlayerBar persists.
2026-05-02 17:32:34 -04:00
bvandeusen 5ef782905e feat(flutter/home): home screen with five sections
Sections mirror the web home: Recently added · Rediscover (albums +
artists) · Most played · Last played artists. Tap routes pushed for
albums/artists; track-tap is wired in the player task. Pull-to-refresh
re-fetches /api/home.
2026-05-02 17:30:42 -04:00
bvandeusen c47deb3c87 feat(flutter/library): card + row widgets (artist/album/track)
HorizontalScrollRow takes an optional shared ScrollController so
multi-row sections (Most played: 3 rows) couple their scroll like the
web HorizontalScrollRow.svelte. Cards fall back to the synced
album-fallback.svg when coverUrl is empty.
2026-05-02 17:29:03 -04:00