diff --git a/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md b/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md new file mode 100644 index 00000000..ca90b623 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md @@ -0,0 +1,4131 @@ +# M7 — Flutter mobile foundation + first slice — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land a Flutter mobile client (iOS + Android) that authenticates against an existing Minstrel server, browses the library, plays music with background audio + lock-screen controls, and surfaces likes everywhere they appear in browse — visually consistent with the SvelteKit SPA via shared design tokens. + +**Architecture:** New top-level `flutter_client/` directory contains a fresh Flutter app using **Riverpod** (state) + **dio** (HTTP, with Bearer-auth interceptor) + **just_audio** wrapped by **audio_service** (background audio + media-session). FabledSword tokens move to a JSON source-of-truth at `web/src/lib/styles/tokens.json`; both web (via a `tokens-to-css.js` generator + Tailwind) and Flutter (via `tool/gen_tokens.dart`) consume the same file. Auth uses Bearer tokens stored in `flutter_secure_storage` (server already supports both cookie and Bearer via `internal/auth/session.go`). Routing uses `go_router` with a shell route so the mini PlayerBar persists across navigation. One backend touch-point: `/healthz` gets a `min_client_version` field so old clients can refuse to operate. + +**Tech Stack:** Flutter 3.24+ · Dart 3.5+ · Riverpod 2.x · dio 5.x · just_audio + audio_service · go_router · flutter_secure_storage · flutter_svg · google_fonts (bundled) · Material 3 · Forgejo Actions for CI · Go 1.23 backend (touch-points only). + +**Spec:** [`docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md`](../specs/2026-05-02-flutter-mobile-foundation-design.md). Read it first — every architectural decision is explained there. + +**Memory dependencies:** `project_design_system.md` (FabledSword tokens), `project_no_github.md` (Forgejo CI under `.forgejo/workflows/`, never `gh` CLI), `project_forgejo_registry_auth.md` (CI uses `REGISTRY_TOKEN` secret), `project_git_workflow.md` (commit on `dev`; PR to `main` separately), `project_web_frontend.md` (web SPA stays primary; Flutter is a sibling client). + +--- + +## File map + +### Backend — modify (single touch-point) + +- `internal/server/server.go` — `handleHealthz` returns `min_client_version` +- `internal/server/server_test.go` — extend healthz test +- `internal/server/version.go` (create) — `MinClientVersion` constant + +### Web — create (token + error-copy source-of-truth refactor) + +- `web/src/lib/styles/tokens.json` — JSON source of truth for FabledSword tokens +- `web/scripts/tokens-to-css.js` — generates `tokens.generated.css` from `tokens.json` +- `web/src/lib/styles/error-copy.json` — error-code → user copy table (existing data, JSON form) + +### Web — modify + +- `web/src/lib/styles/fabledsword-tokens.css` → replaced by `tokens.generated.css`; old file deleted +- `web/src/app.css` — import `tokens.generated.css` instead of `fabledsword-tokens.css` +- `web/src/lib/api/error-copy.ts` — read from `error-copy.json` instead of inline literal +- `web/package.json` — add `tokens` script invoking `node scripts/tokens-to-css.js` +- `web/vite.config.ts` — run `tokens` script as a Vite plugin pre-build hook + +### Flutter — create (new project at `flutter_client/`) + +``` +flutter_client/ +├── pubspec.yaml +├── analysis_options.yaml +├── README.md +├── .gitignore +├── shared/ +│ └── fabledsword.tokens.json # synced from web/src/lib/styles/tokens.json +├── tool/ +│ ├── gen_tokens.dart # tokens.json → lib/theme/tokens.dart +│ └── sync_shared.sh # copies web/.../tokens.json + error-copy.json + svgs +├── assets/ +│ ├── svg/album-fallback.svg # synced from web/static/placeholders/ +│ └── error-copy.json # synced from web/src/lib/styles/error-copy.json +├── lib/ +│ ├── main.dart +│ ├── app.dart +│ ├── theme/ +│ │ ├── tokens.dart # generated (in .gitignore? no — committed for CI determinism) +│ │ ├── theme_extension.dart +│ │ └── theme_data.dart +│ ├── api/ +│ │ ├── client.dart +│ │ ├── errors.dart +│ │ ├── error_copy.dart # loads assets/error-copy.json +│ │ └── endpoints/ +│ │ ├── auth.dart +│ │ ├── library.dart +│ │ ├── likes.dart +│ │ └── health.dart +│ ├── models/ +│ │ ├── user.dart +│ │ ├── artist.dart +│ │ ├── album.dart +│ │ ├── track.dart +│ │ └── home_data.dart +│ ├── auth/ +│ │ ├── server_url_screen.dart +│ │ ├── login_screen.dart +│ │ └── auth_provider.dart +│ ├── library/ +│ │ ├── home_screen.dart +│ │ ├── artist_detail_screen.dart +│ │ ├── album_detail_screen.dart +│ │ ├── library_providers.dart +│ │ └── widgets/ +│ │ ├── horizontal_scroll_row.dart +│ │ ├── artist_card.dart +│ │ ├── album_card.dart +│ │ └── track_row.dart +│ ├── likes/ +│ │ ├── like_button.dart +│ │ └── likes_provider.dart +│ ├── player/ +│ │ ├── audio_handler.dart +│ │ ├── player_provider.dart +│ │ ├── player_bar.dart +│ │ └── now_playing_screen.dart +│ └── shared/ +│ ├── widgets/ +│ │ ├── connection_error_banner.dart +│ │ └── version_gate.dart +│ └── routing.dart +└── test/ + ├── api/ + │ ├── client_test.dart + │ ├── errors_test.dart + │ └── endpoints/ + │ ├── auth_test.dart + │ └── library_test.dart + ├── auth/ + │ ├── auth_provider_test.dart + │ └── login_screen_test.dart + ├── library/ + │ ├── home_screen_test.dart + │ ├── artist_detail_screen_test.dart + │ └── album_detail_screen_test.dart + ├── likes/ + │ └── like_button_test.dart + ├── player/ + │ └── player_provider_test.dart + └── theme/ + └── theme_extension_test.dart +``` + +### CI — create + +- `.forgejo/workflows/flutter.yml` — analyze + test + build APK on push; release APK on tag + +--- + +## Task list + +### Task 1 — Extract FabledSword tokens to JSON source-of-truth + +**Files:** +- Create: `web/src/lib/styles/tokens.json` +- Create: `web/scripts/tokens-to-css.js` +- Create: `web/src/lib/styles/tokens.generated.css` (output, gitignored) +- Modify: `web/.gitignore` — add `src/lib/styles/tokens.generated.css` +- Modify: `web/package.json` — add `tokens` script +- Modify: `web/vite.config.ts` — pre-build hook +- Modify: `web/src/app.css` — import `tokens.generated.css` +- Delete: `web/src/lib/styles/fabledsword-tokens.css` + +- [ ] **Step 1.1: Create `web/src/lib/styles/tokens.json`** + +```json +{ + "colors": { + "obsidian": "#14171A", + "iron": "#1E2228", + "slate": "#2C313A", + "pewter": "#3F4651", + "parchment": "#E8E4D8", + "vellum": "#C2BFB4", + "ash": "#9C9A92", + "moss": "#4A5D3F", + "bronze": "#8B7355", + "oxblood": "#6B2118", + "warning": "#8B6F1E", + "error": "#C04A1F", + "info": "#3D5A6E", + "accent": "#4A6B5C" + }, + "radii": { + "sm": "4px", + "md": "8px", + "lg": "12px", + "xl": "16px" + }, + "fonts": { + "display": "Fraunces", + "body": "Inter", + "mono": "JetBrains Mono" + }, + "fontStacks": { + "display": "'Fraunces', Georgia, serif", + "body": "'Inter', system-ui, sans-serif", + "mono": "'JetBrains Mono', ui-monospace, monospace" + } +} +``` + +- [ ] **Step 1.2: Create `web/scripts/tokens-to-css.js`** + +```js +#!/usr/bin/env node +// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. +// Single source of truth for FabledSword tokens shared with the Flutter +// client. Keep output deterministic — Tailwind reads tokens.json directly, +// the .css emission is for raw CSS consumers. +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const tokensPath = resolve(here, '../src/lib/styles/tokens.json'); +const outPath = resolve(here, '../src/lib/styles/tokens.generated.css'); +const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); + +const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; +for (const [name, value] of Object.entries(tokens.colors)) { + lines.push(` --fs-${name}: ${value};`); +} +for (const [name, value] of Object.entries(tokens.radii)) { + lines.push(` --fs-radius-${name}: ${value};`); +} +lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); +lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); +lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); +lines.push('}', '[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.accent};`, '}', ''); + +writeFileSync(outPath, lines.join('\n')); +console.log(`wrote ${outPath}`); +``` + +- [ ] **Step 1.3: Add `tokens` script + Vite plugin hook** + +In `web/package.json` under `"scripts"`: + +```json +"tokens": "node scripts/tokens-to-css.js", +``` + +In `web/vite.config.ts`, add a Vite plugin that runs the script before build/dev: + +```ts +import { execSync } from 'node:child_process'; + +const tokensPlugin = { + name: 'minstrel-tokens', + buildStart() { + execSync('node scripts/tokens-to-css.js', { stdio: 'inherit' }); + } +}; + +// In `defineConfig({...})`, add to plugins array: +plugins: [tokensPlugin, sveltekit()], +``` + +- [ ] **Step 1.4: Update `web/src/app.css` to import generated CSS** + +Replace `@import './lib/styles/fabledsword-tokens.css';` with: + +```css +@import './lib/styles/tokens.generated.css'; +``` + +- [ ] **Step 1.5: Add `.gitignore` entry** + +Append to `web/.gitignore`: + +``` +src/lib/styles/tokens.generated.css +``` + +- [ ] **Step 1.6: Delete `fabledsword-tokens.css`** + +```bash +rm web/src/lib/styles/fabledsword-tokens.css +``` + +- [ ] **Step 1.7: Run dev to verify tokens generate and styles still load** + +Run: `cd web && npm run tokens && npm run check` +Expected: `tokens.generated.css` written; type-check passes. + +- [ ] **Step 1.8: Commit** + +```bash +git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/app.css web/package.json web/vite.config.ts web/.gitignore +git rm web/src/lib/styles/fabledsword-tokens.css +git commit -F - <<'EOF' +refactor(web): extract design tokens to tokens.json source of truth + +The Flutter client (#356) needs to consume the same FabledSword tokens +the web SPA uses. Move the data into a structured tokens.json file and +generate tokens.generated.css from it on every Vite build. The Flutter +side gets its own generator pointed at the same JSON. + +No visual change — generated CSS matches the previous hand-written file. +EOF +``` + +--- + +### Task 2 — Extract error-copy table to JSON source-of-truth + +**Files:** +- Create: `web/src/lib/styles/error-copy.json` +- Modify: `web/src/lib/api/error-copy.ts` + +**Why:** The Flutter client needs the same error-code → user-facing-copy mapping. Move it to JSON; both clients consume the same table. + +- [ ] **Step 2.1: Inspect existing inline copy table** + +Run: `cat web/src/lib/api/error-copy.ts` +Expected: a `Record` with codes like `invalid_credentials`, `lidarr_unreachable`, `connection_refused`, etc. + +- [ ] **Step 2.2: Create `web/src/lib/styles/error-copy.json`** + +Copy the literal object from `error-copy.ts` into JSON form: + +```json +{ + "invalid_credentials": "That username and password combination didn't match.", + "unauthenticated": "Your session has ended. Please sign in again.", + "connection_refused": "Couldn't reach the server. Check the URL and try again.", + "lidarr_unreachable": "Lidarr is unreachable from the server.", + "lidarr_unauthorized": "Lidarr rejected the server's API key.", + "defaults_incomplete": "An admin needs to finish the Lidarr defaults before this can run.", + "not_found": "We couldn't find what you were looking for.", + "server_error": "The server hit an unexpected error.", + "version_too_old": "This client is too old to talk to this server. Please update.", + "unknown": "Something went wrong." +} +``` + +(Use the actual codes/messages from `error-copy.ts`; the list above is representative — read the file and copy verbatim.) + +- [ ] **Step 2.3: Refactor `error-copy.ts` to read JSON** + +```ts +import errorCopyJson from '../styles/error-copy.json'; + +export const ERROR_COPY: Readonly> = errorCopyJson; + +export function copyForCode(code: string): string { + return ERROR_COPY[code] ?? ERROR_COPY.unknown ?? 'Something went wrong.'; +} +``` + +- [ ] **Step 2.4: Run check + tests** + +Run: `cd web && npm run check && npm test -- --run error-copy` +Expected: pass. + +- [ ] **Step 2.5: Commit** + +```bash +git add web/src/lib/styles/error-copy.json web/src/lib/api/error-copy.ts +git commit -F - <<'EOF' +refactor(web): extract error-copy table to JSON + +Lets the Flutter client (#356) consume the same code → user-facing-copy +mapping by syncing the JSON file. Behavior on the web side is unchanged. +EOF +``` + +--- + +### Task 3 — Server: add `min_client_version` to `/healthz` + +**Files:** +- Create: `internal/server/version.go` +- Modify: `internal/server/server.go` (`handleHealthz`) +- Modify: `internal/server/server_test.go` + +- [ ] **Step 3.1: Write the failing test** + +Add to `internal/server/server_test.go`: + +```go +func TestHealthz_IncludesMinClientVersion(t *testing.T) { + t.Parallel() + s := &Server{} + r := chi.NewRouter() + r.Get("/healthz", s.handleHealthz) + ts := httptest.NewServer(r) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer resp.Body.Close() + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Errorf("status: got %q want \"ok\"", body["status"]) + } + if body["min_client_version"] == "" { + t.Error("min_client_version is empty; clients can't enforce pairing") + } +} +``` + +- [ ] **Step 3.2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestHealthz_IncludesMinClientVersion -v` +Expected: FAIL with `min_client_version is empty`. + +- [ ] **Step 3.3: Create `internal/server/version.go`** + +```go +package server + +// MinClientVersion is the lowest mobile-client semver that this server +// accepts. Bump it when a server change requires a paired client update; +// older clients see version_too_old at /healthz and refuse to operate. +const MinClientVersion = "0.1.0" +``` + +- [ ] **Step 3.4: Update `handleHealthz` in `internal/server/server.go`** + +Replace the existing handler (around line 103): + +```go +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "min_client_version": MinClientVersion, + }) +} +``` + +- [ ] **Step 3.5: Run test to verify it passes** + +Run: `go test ./internal/server/ -run TestHealthz -v` +Expected: PASS. + +- [ ] **Step 3.6: Commit** + +```bash +git add internal/server/version.go internal/server/server.go internal/server/server_test.go +git commit -F - <<'EOF' +feat(server): /healthz returns min_client_version + +Lets the M7 Flutter client refuse to operate against an incompatible +server (e.g., new endpoints the client depends on, breaking schema +shifts). The version constant is bumped on each release where the +client/server contract changes; old clients show the user a blocking +"update required" modal pointed at the latest APK / TestFlight build. +EOF +``` + +--- + +### Task 4 — Flutter project scaffold + dependencies + +**Files:** +- Create: `flutter_client/pubspec.yaml` +- Create: `flutter_client/analysis_options.yaml` +- Create: `flutter_client/README.md` +- Create: `flutter_client/.gitignore` +- Create: `flutter_client/lib/main.dart` (placeholder) +- Create: `flutter_client/lib/app.dart` (placeholder) +- Create: `flutter_client/test/smoke_test.dart` +- Create: `flutter_client/android/`, `flutter_client/ios/` (via `flutter create`) + +- [ ] **Step 4.1: Run `flutter create`** + +Run from repo root: + +```bash +flutter create \ + --platforms=android,ios \ + --org com.fabledsword \ + --project-name minstrel \ + --description "Minstrel mobile client" \ + flutter_client +``` + +Expected: `flutter_client/` populated with iOS + Android shells. + +- [ ] **Step 4.2: Replace generated `pubspec.yaml`** + +Overwrite `flutter_client/pubspec.yaml`: + +```yaml +name: minstrel +description: Minstrel mobile client +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.1 + dio: ^5.7.0 + just_audio: ^0.9.40 + audio_service: ^0.18.15 + flutter_secure_storage: ^9.2.2 + go_router: ^14.6.1 + flutter_svg: ^2.0.16 + google_fonts: ^6.2.1 + package_info_plus: ^8.0.0 + pub_semver: ^2.1.4 + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + mocktail: ^1.0.4 + +flutter: + uses-material-design: true + assets: + - assets/svg/ + - assets/error-copy.json + - shared/fabledsword.tokens.json +``` + +- [ ] **Step 4.3: Replace generated `analysis_options.yaml`** + +```yaml +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - lib/theme/tokens.dart # generated; allow magic numbers + +linter: + rules: + avoid_print: true + prefer_const_constructors: true + sort_pub_dependencies: false +``` + +- [ ] **Step 4.4: Replace `lib/main.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; + +void main() { + runApp(const ProviderScope(child: MinstrelApp())); +} +``` + +- [ ] **Step 4.5: Replace `lib/app.dart` with stub** + +```dart +import 'package:flutter/material.dart'; + +class MinstrelApp extends StatelessWidget { + const MinstrelApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Minstrel', + home: Scaffold( + body: Center(child: Text('Minstrel — scaffold')), + ), + ); + } +} +``` + +- [ ] **Step 4.6: Write smoke test** + +Create `flutter_client/test/smoke_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/app.dart'; + +void main() { + testWidgets('app boots and renders scaffold marker', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MinstrelApp())); + expect(find.text('Minstrel — scaffold'), findsOneWidget); + }); +} +``` + +- [ ] **Step 4.7: Run tests + analyzer** + +```bash +cd flutter_client +flutter pub get +flutter analyze +flutter test +``` + +Expected: analyze clean, smoke test passes. + +- [ ] **Step 4.8: Update root `.gitignore`** + +Append to repo root `.gitignore`: + +``` +# Flutter +flutter_client/.dart_tool/ +flutter_client/.flutter-plugins +flutter_client/.flutter-plugins-dependencies +flutter_client/build/ +flutter_client/.idea/ +flutter_client/ios/Podfile.lock +flutter_client/ios/Pods/ +flutter_client/android/.gradle/ +flutter_client/android/app/build/ +flutter_client/android/local.properties +flutter_client/android/key.properties +flutter_client/*.iml +``` + +- [ ] **Step 4.9: Commit** + +```bash +git add flutter_client/ .gitignore +git commit -F - <<'EOF' +feat(flutter): project scaffold with pubspec and smoke test + +iOS + Android targets only; desktop disabled (per spec, Tauri-wrapped +SvelteKit handles desktop in v1.1). Riverpod + dio + just_audio + +audio_service + go_router pinned in pubspec. Smoke test confirms the +ProviderScope wiring boots a Material app. +EOF +``` + +--- + +### Task 5 — Token sync + Dart token generator + +**Files:** +- Create: `flutter_client/shared/fabledsword.tokens.json` (synced) +- Create: `flutter_client/tool/sync_shared.sh` +- Create: `flutter_client/tool/gen_tokens.dart` +- Create: `flutter_client/lib/theme/tokens.dart` (generated, committed) + +- [ ] **Step 5.1: Create `flutter_client/tool/sync_shared.sh`** + +```bash +#!/usr/bin/env bash +# Copies shared assets from web/ into flutter_client/. Run before +# `flutter build` and as part of CI. Idempotent. +set -euo pipefail + +cd "$(dirname "$0")/.." + +cp ../web/src/lib/styles/tokens.json shared/fabledsword.tokens.json +cp ../web/src/lib/styles/error-copy.json assets/error-copy.json +mkdir -p assets/svg +cp ../web/static/placeholders/album-fallback.svg assets/svg/album-fallback.svg + +echo "shared assets synced from web/" +``` + +```bash +chmod +x flutter_client/tool/sync_shared.sh +``` + +- [ ] **Step 5.2: Run sync to populate `shared/` and `assets/`** + +```bash +flutter_client/tool/sync_shared.sh +``` + +Expected: `flutter_client/shared/fabledsword.tokens.json`, `flutter_client/assets/error-copy.json`, `flutter_client/assets/svg/album-fallback.svg` all created. + +- [ ] **Step 5.3: Create `flutter_client/tool/gen_tokens.dart`** + +```dart +// Reads shared/fabledsword.tokens.json and emits lib/theme/tokens.dart. +// Run via `dart run tool/gen_tokens.dart`. The generated file is +// committed (CI validates it matches the JSON to catch drift). +import 'dart:convert'; +import 'dart:io'; + +void main() { + final jsonFile = File('shared/fabledsword.tokens.json'); + final tokens = jsonDecode(jsonFile.readAsStringSync()) as Map; + final colors = (tokens['colors'] as Map).cast(); + final radii = (tokens['radii'] as Map).cast(); + final fonts = (tokens['fonts'] as Map).cast(); + + final out = StringBuffer() + ..writeln('// GENERATED — do not edit. Source: shared/fabledsword.tokens.json') + ..writeln('// Run `dart run tool/gen_tokens.dart` to regenerate.') + ..writeln("import 'package:flutter/material.dart';") + ..writeln() + ..writeln('class FabledSwordTokens {'); + + colors.forEach((name, hex) { + final value = hex.replaceFirst('#', '0xFF'); + out.writeln(' static const Color ${_camel(name)} = Color($value);'); + }); + + radii.forEach((name, px) { + final v = px.replaceAll('px', ''); + out.writeln(' static const double radius${_pascal(name)} = $v;'); + }); + + fonts.forEach((name, family) { + out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); + }); + + out.writeln('}'); + + File('lib/theme/tokens.dart').writeAsStringSync(out.toString()); + stdout.writeln('wrote lib/theme/tokens.dart'); +} + +String _camel(String s) => s; // tokens are already lowerCamel-friendly +String _pascal(String s) => s[0].toUpperCase() + s.substring(1); +``` + +- [ ] **Step 5.4: Run generator** + +```bash +cd flutter_client +dart run tool/gen_tokens.dart +``` + +Expected: `lib/theme/tokens.dart` written. + +- [ ] **Step 5.5: Verify generated file shape** + +Run: `head -20 flutter_client/lib/theme/tokens.dart` +Expected: contains `static const Color accent = Color(0xFF4A6B5C);` and friends. + +- [ ] **Step 5.6: Add CI drift check (deferred to Task 26 CI workflow but recorded here)** + +The CI step `dart run tool/gen_tokens.dart && git diff --exit-code lib/theme/tokens.dart` will fail if the generator output drifts from the committed copy. Recorded for the workflow task. + +- [ ] **Step 5.7: Commit** + +```bash +git add flutter_client/shared/ flutter_client/assets/ flutter_client/tool/ flutter_client/lib/theme/tokens.dart +git commit -F - <<'EOF' +feat(flutter): token + asset sync from web; gen_tokens.dart + +shared/fabledsword.tokens.json mirrors web/src/lib/styles/tokens.json; +assets/error-copy.json mirrors the web's error-code copy table; +assets/svg/album-fallback.svg mirrors the web placeholder. Together +these give Flutter the same visual + copy starting point as the SPA. +gen_tokens.dart emits lib/theme/tokens.dart from the JSON. +EOF +``` + +--- + +### Task 6 — Theme: ThemeExtension + ThemeData factory + +**Files:** +- Create: `flutter_client/lib/theme/theme_extension.dart` +- Create: `flutter_client/lib/theme/theme_data.dart` +- Create: `flutter_client/test/theme/theme_extension_test.dart` +- Modify: `flutter_client/lib/app.dart` — wire ThemeData + +- [ ] **Step 6.1: Write the failing test** + +`flutter_client/test/theme/theme_extension_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/theme/theme_data.dart'; +import 'package:minstrel/theme/theme_extension.dart'; +import 'package:minstrel/theme/tokens.dart'; + +void main() { + testWidgets('Theme exposes FabledSwordTheme with expected accent', (tester) async { + late FabledSwordTheme captured; + await tester.pumpWidget( + MaterialApp( + theme: buildThemeData(), + home: Builder( + builder: (ctx) { + captured = Theme.of(ctx).extension()!; + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(captured.accent, FabledSwordTokens.accent); + expect(captured.parchment, FabledSwordTokens.parchment); + expect(captured.body.fontFamily, FabledSwordTokens.fontBody); + }); +} +``` + +- [ ] **Step 6.2: Run test to verify it fails** + +Run: `cd flutter_client && flutter test test/theme/` +Expected: FAIL with import errors. + +- [ ] **Step 6.3: Create `lib/theme/theme_extension.dart`** + +```dart +import 'package:flutter/material.dart'; + +import 'tokens.dart'; + +class FabledSwordTheme extends ThemeExtension { + const FabledSwordTheme({ + required this.accent, + required this.obsidian, + required this.iron, + required this.slate, + required this.pewter, + required this.parchment, + required this.vellum, + required this.ash, + required this.moss, + required this.bronze, + required this.oxblood, + required this.warning, + required this.error, + required this.info, + required this.display, + required this.body, + required this.mono, + }); + + final Color accent; + final Color obsidian, iron, slate, pewter; + final Color parchment, vellum, ash; + final Color moss, bronze, oxblood; + final Color warning, error, info; + final TextStyle display, body, mono; + + static FabledSwordTheme fromTokens() => FabledSwordTheme( + accent: FabledSwordTokens.accent, + obsidian: FabledSwordTokens.obsidian, + iron: FabledSwordTokens.iron, + slate: FabledSwordTokens.slate, + pewter: FabledSwordTokens.pewter, + parchment: FabledSwordTokens.parchment, + vellum: FabledSwordTokens.vellum, + ash: FabledSwordTokens.ash, + moss: FabledSwordTokens.moss, + bronze: FabledSwordTokens.bronze, + oxblood: FabledSwordTokens.oxblood, + warning: FabledSwordTokens.warning, + error: FabledSwordTokens.error, + info: FabledSwordTokens.info, + display: TextStyle(fontFamily: FabledSwordTokens.fontDisplay), + body: TextStyle(fontFamily: FabledSwordTokens.fontBody), + mono: TextStyle(fontFamily: FabledSwordTokens.fontMono), + ); + + @override + FabledSwordTheme copyWith({ + Color? accent, + }) => + FabledSwordTheme( + accent: accent ?? this.accent, + obsidian: obsidian, iron: iron, slate: slate, pewter: pewter, + parchment: parchment, vellum: vellum, ash: ash, + moss: moss, bronze: bronze, oxblood: oxblood, + warning: warning, error: error, info: info, + display: display, body: body, mono: mono, + ); + + @override + FabledSwordTheme lerp(ThemeExtension? other, double t) => this; +} +``` + +- [ ] **Step 6.4: Create `lib/theme/theme_data.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'theme_extension.dart'; +import 'tokens.dart'; + +ThemeData buildThemeData() { + final fs = FabledSwordTheme.fromTokens(); + final colorScheme = ColorScheme.dark( + surface: fs.iron, + onSurface: fs.parchment, + primary: fs.accent, + onPrimary: fs.parchment, + secondary: fs.moss, + error: fs.error, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: fs.obsidian, + textTheme: GoogleFonts.interTextTheme().apply( + bodyColor: fs.parchment, + displayColor: fs.parchment, + ), + fontFamily: FabledSwordTokens.fontBody, + extensions: >[fs], + ); +} +``` + +- [ ] **Step 6.5: Wire ThemeData into the app** + +In `lib/app.dart`, replace the body: + +```dart +import 'package:flutter/material.dart'; + +import 'theme/theme_data.dart'; + +class MinstrelApp extends StatelessWidget { + const MinstrelApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Minstrel', + theme: buildThemeData(), + home: const Scaffold( + body: Center(child: Text('Minstrel — scaffold')), + ), + ); + } +} +``` + +- [ ] **Step 6.6: Run tests** + +Run: `cd flutter_client && flutter test` +Expected: PASS. + +- [ ] **Step 6.7: Commit** + +```bash +git add flutter_client/lib/theme/ flutter_client/test/theme/ flutter_client/lib/app.dart +git commit -F - <<'EOF' +feat(flutter): FabledSwordTheme ThemeExtension + Material 3 ThemeData + +Widgets read tokens via Theme.of(ctx).extension(). +Material 3 ColorScheme bound to FabledSword tokens. Fonts come from +google_fonts (bundled at build via the package's runtime cache; will +switch to bundled .ttf if airplane-mode-from-cold-launch becomes a +goal — not in this slice). +EOF +``` + +--- + +### Task 7 — API: dio client + auth interceptor + ApiError + +**Files:** +- Create: `flutter_client/lib/api/client.dart` +- Create: `flutter_client/lib/api/errors.dart` +- Create: `flutter_client/lib/api/error_copy.dart` +- Create: `flutter_client/test/api/client_test.dart` +- Create: `flutter_client/test/api/errors_test.dart` + +- [ ] **Step 7.1: Write failing test for ApiError envelope handling** + +`flutter_client/test/api/errors_test.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/errors.dart'; + +void main() { + RequestOptions opts() => RequestOptions(path: '/x'); + + group('ApiError.fromDio', () { + test('flat envelope: {error: "code"}', () { + final d = DioException( + requestOptions: opts(), + response: Response( + requestOptions: opts(), + statusCode: 503, + data: {'error': 'lidarr_unreachable'}, + ), + ); + final e = ApiError.fromDio(d); + expect(e.code, 'lidarr_unreachable'); + expect(e.status, 503); + }); + + test('nested envelope: {error: {code, message}}', () { + final d = DioException( + requestOptions: opts(), + response: Response( + requestOptions: opts(), + statusCode: 400, + data: {'error': {'code': 'bad_request', 'message': 'invalid JSON body'}}, + ), + ); + final e = ApiError.fromDio(d); + expect(e.code, 'bad_request'); + expect(e.message, 'invalid JSON body'); + }); + + test('connection refused: code is connection_refused', () { + final d = DioException( + requestOptions: opts(), + type: DioExceptionType.connectionError, + error: 'Connection refused', + ); + final e = ApiError.fromDio(d); + expect(e.code, 'connection_refused'); + }); + }); +} +``` + +- [ ] **Step 7.2: Run test to verify failure** + +Run: `cd flutter_client && flutter test test/api/errors_test.dart` +Expected: FAIL with import errors. + +- [ ] **Step 7.3: Create `lib/api/errors.dart`** + +```dart +import 'package:dio/dio.dart'; + +class ApiError implements Exception { + ApiError({required this.code, required this.message, required this.status}); + + final String code; + final String message; + final int status; + + factory ApiError.fromDio(DioException e) { + if (e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout) { + return ApiError(code: 'connection_refused', message: 'Connection refused', status: 0); + } + final response = e.response; + final data = response?.data; + if (data is Map) { + final field = data['error']; + if (field is String) { + return ApiError(code: field, message: field, status: response?.statusCode ?? 0); + } + if (field is Map) { + final code = field['code']?.toString() ?? 'unknown'; + final msg = field['message']?.toString() ?? code; + return ApiError(code: code, message: msg, status: response?.statusCode ?? 0); + } + } + final status = response?.statusCode ?? 0; + if (status == 401) return ApiError(code: 'unauthenticated', message: 'unauthenticated', status: status); + if (status == 404) return ApiError(code: 'not_found', message: 'not_found', status: status); + return ApiError(code: 'unknown', message: e.message ?? 'unknown', status: status); + } +} +``` + +- [ ] **Step 7.4: Create `lib/api/error_copy.dart`** + +```dart +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; + +class ErrorCopy { + ErrorCopy._(this._table); + final Map _table; + + static ErrorCopy? _instance; + + static Future load() async { + if (_instance != null) return _instance!; + final raw = await rootBundle.loadString('assets/error-copy.json'); + final m = (jsonDecode(raw) as Map).cast().map( + (k, v) => MapEntry(k, v.toString()), + ); + _instance = ErrorCopy._(m); + return _instance!; + } + + String forCode(String code) => + _table[code] ?? _table['unknown'] ?? 'Something went wrong.'; +} +``` + +- [ ] **Step 7.5: Write failing test for client** + +`flutter_client/test/api/client_test.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/client.dart'; + +void main() { + test('client attaches Bearer token from token resolver', () async { + final d = ApiClient.buildDio( + baseUrl: 'http://example/', + tokenResolver: () async => 'tok-123', + ); + Map? captured; + d.options.headers['X-Test'] = 'baseline'; + d.interceptors.add( + InterceptorsWrapper(onRequest: (opts, h) { + captured = Map.from(opts.headers); + h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); + }), + ); + try { + await d.get('/whatever'); + } catch (_) {} + expect(captured?['Authorization'], 'Bearer tok-123'); + }); + + test('client omits Authorization when resolver returns null', () async { + final d = ApiClient.buildDio( + baseUrl: 'http://example/', + tokenResolver: () async => null, + ); + Map? captured; + d.interceptors.add( + InterceptorsWrapper(onRequest: (opts, h) { + captured = Map.from(opts.headers); + h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); + }), + ); + try { + await d.get('/whatever'); + } catch (_) {} + expect(captured?.containsKey('Authorization'), isFalse); + }); +} +``` + +- [ ] **Step 7.6: Create `lib/api/client.dart`** + +```dart +import 'package:dio/dio.dart'; + +typedef TokenResolver = Future Function(); + +class ApiClient { + /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth + /// interceptor that pulls the current token from [tokenResolver] + /// on every request. Server already supports cookie OR bearer + /// (internal/auth/session.go); we use bearer to skip cookie-jar. + static Dio buildDio({ + required String baseUrl, + required TokenResolver tokenResolver, + }) { + final d = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 30), + contentType: Headers.jsonContentType, + responseType: ResponseType.json, + )); + d.interceptors.add(InterceptorsWrapper(onRequest: (opts, handler) async { + final t = await tokenResolver(); + if (t != null && t.isNotEmpty) { + opts.headers['Authorization'] = 'Bearer $t'; + } + handler.next(opts); + })); + return d; + } +} +``` + +- [ ] **Step 7.7: Run all api tests** + +```bash +cd flutter_client && flutter test test/api/ +``` + +Expected: PASS. + +- [ ] **Step 7.8: Commit** + +```bash +git add flutter_client/lib/api/ flutter_client/test/api/ +git commit -F - <<'EOF' +feat(flutter/api): dio client with Bearer interceptor + ApiError + +ApiError.fromDio handles both server envelope shapes: {"error":"code"} +(admin endpoints) and {"error":{"code","message"}} (most others). Same +dual-shape problem the web apiFetch already solves. Token comes from a +resolver function so the auth provider can swap implementations +without touching the client. +EOF +``` + +--- + +### Task 8 — Models (User, Artist, Album, Track, HomeData) + +**Files:** +- Create: `flutter_client/lib/models/user.dart` +- Create: `flutter_client/lib/models/artist.dart` +- Create: `flutter_client/lib/models/album.dart` +- Create: `flutter_client/lib/models/track.dart` +- Create: `flutter_client/lib/models/home_data.dart` +- Create: `flutter_client/test/models/models_test.dart` + +**Why:** DTOs mirror `web/src/lib/api/types.ts`. Hand-written for slice 1 (codegen deferred per spec §10). + +- [ ] **Step 8.1: Read the web types as the contract** + +Run: `cat web/src/lib/api/types.ts | head -120` +Expected: types like `User`, `ArtistRef`, `AlbumRef`, `TrackRef`, `HomePayload`. The Flutter models mirror these field names exactly. + +- [ ] **Step 8.2: Create `lib/models/user.dart`** + +```dart +class User { + const User({required this.id, required this.username, required this.isAdmin}); + + final String id; + final String username; + final bool isAdmin; + + factory User.fromJson(Map j) => User( + id: j['id'] as String, + username: j['username'] as String, + isAdmin: j['is_admin'] as bool? ?? false, + ); +} +``` + +- [ ] **Step 8.3: Create `lib/models/artist.dart`** + +```dart +class ArtistRef { + const ArtistRef({ + required this.id, + required this.name, + this.coverArtUrl, + }); + + final String id; + final String name; + final String? coverArtUrl; + + factory ArtistRef.fromJson(Map j) => ArtistRef( + id: j['id'] as String, + name: j['name'] as String, + coverArtUrl: j['cover_art_url'] as String?, + ); +} +``` + +- [ ] **Step 8.4: Create `lib/models/album.dart`** + +```dart +class AlbumRef { + const AlbumRef({ + required this.id, + required this.title, + required this.artistId, + required this.artistName, + this.coverArtUrl, + }); + + final String id; + final String title; + final String artistId; + final String artistName; + final String? coverArtUrl; + + factory AlbumRef.fromJson(Map j) => AlbumRef( + id: j['id'] as String, + title: j['title'] as String, + artistId: j['artist_id'] as String, + artistName: j['artist_name'] as String? ?? '', + coverArtUrl: j['cover_art_url'] as String?, + ); +} +``` + +- [ ] **Step 8.5: Create `lib/models/track.dart`** + +```dart +class TrackRef { + const TrackRef({ + required this.id, + required this.title, + required this.albumId, + required this.albumTitle, + required this.artistId, + required this.artistName, + required this.durationMs, + this.trackNumber, + }); + + final String id; + final String title; + final String albumId; + final String albumTitle; + final String artistId; + final String artistName; + final int durationMs; + final int? trackNumber; + + factory TrackRef.fromJson(Map j) => TrackRef( + id: j['id'] as String, + title: j['title'] as String, + albumId: j['album_id'] as String, + albumTitle: j['album_title'] as String? ?? '', + artistId: j['artist_id'] as String, + artistName: j['artist_name'] as String? ?? '', + durationMs: (j['duration_ms'] as num?)?.toInt() ?? 0, + trackNumber: (j['track_number'] as num?)?.toInt(), + ); +} +``` + +- [ ] **Step 8.6: Create `lib/models/home_data.dart`** + +```dart +import 'album.dart'; +import 'artist.dart'; +import 'track.dart'; + +class HomeData { + const HomeData({ + required this.recentlyAddedAlbums, + required this.rediscoverAlbums, + required this.rediscoverArtists, + required this.mostPlayedTracks, + required this.lastPlayedArtists, + }); + + final List recentlyAddedAlbums; + final List rediscoverAlbums; + final List rediscoverArtists; + final List mostPlayedTracks; + final List lastPlayedArtists; + + factory HomeData.fromJson(Map j) => HomeData( + recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson), + rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson), + rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson), + mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson), + lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson), + ); + + static List _list( + Map j, + String key, + T Function(Map) parse, + ) { + final raw = j[key] as List? ?? const []; + return raw.map((e) => parse((e as Map).cast())).toList(growable: false); + } +} +``` + +- [ ] **Step 8.7: Write a parsing test** + +`flutter_client/test/models/models_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:minstrel/models/home_data.dart'; + +void main() { + test('HomeData.fromJson handles missing sections as empty lists', () { + final h = HomeData.fromJson({}); + expect(h.recentlyAddedAlbums, isEmpty); + expect(h.rediscoverArtists, isEmpty); + }); + + test('HomeData.fromJson parses populated payload', () { + final h = HomeData.fromJson({ + 'recently_added_albums': [ + { + 'id': 'al-1', + 'title': 'Geogaddi', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + 'cover_art_url': '/api/albums/al-1/cover', + } + ], + }); + expect(h.recentlyAddedAlbums.single.title, 'Geogaddi'); + expect(h.recentlyAddedAlbums.single.coverArtUrl, '/api/albums/al-1/cover'); + }); +} +``` + +- [ ] **Step 8.8: Run tests** + +```bash +cd flutter_client && flutter test test/models/ +``` + +Expected: PASS. + +- [ ] **Step 8.9: Commit** + +```bash +git add flutter_client/lib/models/ flutter_client/test/models/ +git commit -F - <<'EOF' +feat(flutter/models): User, ArtistRef, AlbumRef, TrackRef, HomeData + +Field names mirror web/src/lib/api/types.ts exactly so a contract drift +between server and either client surfaces in both at the same time. +Hand-written DTOs per spec §10 — codegen revisits at ~30 models. +EOF +``` + +--- + +### Task 9 — Auth: server URL screen + auth provider + secure storage + +**Files:** +- Create: `flutter_client/lib/auth/auth_provider.dart` +- Create: `flutter_client/lib/auth/server_url_screen.dart` +- Create: `flutter_client/lib/api/endpoints/health.dart` +- Create: `flutter_client/test/auth/auth_provider_test.dart` + +- [ ] **Step 9.1: Create `lib/api/endpoints/health.dart`** + +```dart +import 'package:dio/dio.dart'; + +class HealthApi { + HealthApi(this._dio); + final Dio _dio; + + /// Returns {status, min_client_version}. + Future> check() async { + final r = await _dio.get>('/healthz'); + return (r.data ?? const {}) + .map((k, v) => MapEntry(k, v.toString())); + } +} +``` + +- [ ] **Step 9.2: Write failing test for auth provider** + +`flutter_client/test/auth/auth_provider_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:minstrel/auth/auth_provider.dart'; + +class _FakeStorage implements FlutterSecureStorage { + final _m = {}; + @override + Future read({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async => _m[key]; + @override + Future write({required String key, required String? value, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async { + if (value == null) _m.remove(key); else _m[key] = value; + } + @override + Future delete({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async => _m.remove(key); + // Other interface members not exercised in tests; intentionally unimplemented. + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +void main() { + test('saves and reads server URL', () async { + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(_FakeStorage()), + ]); + addTearDown(container.dispose); + + await container.read(authControllerProvider.notifier).setServerUrl('http://localhost:8080'); + + final url = await container.read(serverUrlProvider.future); + expect(url, 'http://localhost:8080'); + }); + + test('clearing token transitions auth state to logged out', () async { + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(_FakeStorage()), + ]); + addTearDown(container.dispose); + + final ctrl = container.read(authControllerProvider.notifier); + await ctrl.setServerUrl('http://x'); + await ctrl.setSession(token: 't', userJson: '{"id":"u","username":"u","is_admin":false}'); + expect((await container.read(authControllerProvider.future))?.username, 'u'); + + await ctrl.clearSession(); + expect(await container.read(authControllerProvider.future), isNull); + }); +} +``` + +- [ ] **Step 9.3: Run test to verify failure** + +Run: `cd flutter_client && flutter test test/auth/auth_provider_test.dart` +Expected: FAIL with import / undefined errors. + +- [ ] **Step 9.4: Create `lib/auth/auth_provider.dart`** + +```dart +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../models/user.dart'; + +const _kServerUrl = 'server_url'; +const _kSessionToken = 'session_token'; +const _kCurrentUser = 'current_user'; + +final secureStorageProvider = Provider( + (ref) => const FlutterSecureStorage(), +); + +final serverUrlProvider = FutureProvider((ref) async { + return ref.watch(secureStorageProvider).read(key: _kServerUrl); +}); + +final sessionTokenProvider = FutureProvider((ref) async { + return ref.watch(secureStorageProvider).read(key: _kSessionToken); +}); + +class AuthController extends AsyncNotifier { + late FlutterSecureStorage _storage; + + @override + Future build() async { + _storage = ref.watch(secureStorageProvider); + final raw = await _storage.read(key: _kCurrentUser); + if (raw == null) return null; + return User.fromJson(jsonDecode(raw) as Map); + } + + Future setServerUrl(String url) async { + await _storage.write(key: _kServerUrl, value: url); + ref.invalidate(serverUrlProvider); + } + + Future setSession({required String token, required String userJson}) async { + await _storage.write(key: _kSessionToken, value: token); + await _storage.write(key: _kCurrentUser, value: userJson); + ref.invalidate(sessionTokenProvider); + state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); + } + + Future clearSession() async { + await _storage.delete(key: _kSessionToken); + await _storage.delete(key: _kCurrentUser); + ref.invalidate(sessionTokenProvider); + state = const AsyncData(null); + } +} + +final authControllerProvider = + AsyncNotifierProvider(AuthController.new); +``` + +- [ ] **Step 9.5: Run test to verify it passes** + +Run: `cd flutter_client && flutter test test/auth/auth_provider_test.dart` +Expected: PASS. + +- [ ] **Step 9.6: Create `lib/auth/server_url_screen.dart`** + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/health.dart'; +import '../theme/theme_extension.dart'; +import 'auth_provider.dart'; + +class ServerUrlScreen extends ConsumerStatefulWidget { + const ServerUrlScreen({super.key}); + + @override + ConsumerState createState() => _ServerUrlScreenState(); +} + +class _ServerUrlScreenState extends ConsumerState { + final _ctrl = TextEditingController(); + bool _busy = false; + String? _error; + + Future _connect() async { + final url = _ctrl.text.trim(); + if (url.isEmpty) { + setState(() => _error = 'Enter a server URL.'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + final dio = Dio(BaseOptions(baseUrl: url, connectTimeout: const Duration(seconds: 5))); + final body = await HealthApi(dio).check(); + if (body['status'] != 'ok') throw Exception('unhealthy'); + await ref.read(authControllerProvider.notifier).setServerUrl(url); + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/login'); + } on DioException catch (_) { + setState(() => _error = "Couldn't reach that server."); + } catch (_) { + setState(() => _error = "Couldn't reach that server."); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 48), + Text('Connect to your Minstrel', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), + const SizedBox(height: 24), + TextField( + controller: _ctrl, + keyboardType: TextInputType.url, + decoration: const InputDecoration(labelText: 'Server URL', hintText: 'https://music.example.com'), + ), + if (_error != null) Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: TextStyle(color: fs.error)), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : _connect, + child: _busy + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Connect'), + ), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 9.7: Commit** + +```bash +git add flutter_client/lib/auth/ flutter_client/lib/api/endpoints/health.dart flutter_client/test/auth/ +git commit -F - <<'EOF' +feat(flutter/auth): server URL screen + auth provider + secure storage + +AuthController is an AsyncNotifier holding the currently-logged-in +User. server_url, session_token, current_user all live in +flutter_secure_storage. ServerUrlScreen probes /healthz before saving +the URL so we don't store a bogus base. +EOF +``` + +--- + +### Task 10 — Auth: login screen + auth API + flow routing + +**Files:** +- Create: `flutter_client/lib/api/endpoints/auth.dart` +- Create: `flutter_client/lib/auth/login_screen.dart` +- Create: `flutter_client/test/auth/login_screen_test.dart` + +- [ ] **Step 10.1: Create `lib/api/endpoints/auth.dart`** + +```dart +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../models/user.dart'; + +class AuthApi { + AuthApi(this._dio); + final Dio _dio; + + /// Returns (token, user, rawUserJson) on success. Server emits + /// LoginResponse{token, user{id, username, is_admin}}. + Future<({String token, User user, String rawUserJson})> login({ + required String username, + required String password, + }) async { + final r = await _dio.post>( + '/api/auth/login', + data: {'username': username, 'password': password}, + ); + final body = r.data!; + final userMap = (body['user'] as Map).cast(); + return ( + token: body['token'] as String, + user: User.fromJson(userMap), + rawUserJson: jsonEncode(userMap), + ); + } + + Future logout() async { + await _dio.post('/api/auth/logout'); + } +} +``` + +- [ ] **Step 10.2: Create `lib/auth/login_screen.dart`** + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/client.dart'; +import '../api/endpoints/auth.dart'; +import '../api/error_copy.dart'; +import '../api/errors.dart'; +import '../theme/theme_extension.dart'; +import 'auth_provider.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _user = TextEditingController(); + final _pass = TextEditingController(); + bool _busy = false; + String? _error; + + Future _submit() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final url = await ref.read(serverUrlProvider.future); + if (url == null) { + Navigator.of(context).pushReplacementNamed('/server-url'); + return; + } + final dio = ApiClient.buildDio( + baseUrl: url, + tokenResolver: () async => null, + ); + final res = await AuthApi(dio).login( + username: _user.text.trim(), + password: _pass.text, + ); + await ref.read(authControllerProvider.notifier).setSession( + token: res.token, + userJson: res.rawUserJson, + ); + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/home'); + } on DioException catch (e) { + final code = ApiError.fromDio(e).code; + final copy = (await ErrorCopy.load()).forCode(code); + setState(() => _error = copy); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 48), + Text('Sign in', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), + const SizedBox(height: 24), + TextField(controller: _user, decoration: const InputDecoration(labelText: 'Username')), + const SizedBox(height: 12), + TextField(controller: _pass, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), + if (_error != null) Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: TextStyle(color: fs.error)), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sign in'), + ), + TextButton( + onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'), + child: const Text('Change server URL'), + ), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 10.3: Write a smoke test for the screen** + +`flutter_client/test/auth/login_screen_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/auth/login_screen.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + testWidgets('login screen renders both fields and the submit button', (tester) async { + await tester.pumpWidget(ProviderScope( + child: MaterialApp( + theme: buildThemeData(), + home: const LoginScreen(), + ), + )); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text('Sign in'), findsNWidgets(2)); // header + button + }); +} +``` + +- [ ] **Step 10.4: Run tests** + +```bash +cd flutter_client && flutter test test/auth/ +``` + +Expected: PASS. + +- [ ] **Step 10.5: Commit** + +```bash +git add flutter_client/lib/auth/login_screen.dart flutter_client/lib/api/endpoints/auth.dart flutter_client/test/auth/login_screen_test.dart +git commit -F - <<'EOF' +feat(flutter/auth): login screen + AuthApi.login posting to /api/auth/login + +Login uses a non-authenticated dio (token resolver returns null) since +we don't have one yet. On success, setSession persists token + user +into secure storage and the AuthController state flips, which the +router watches to navigate. +EOF +``` + +--- + +### Task 11 — Routing shell with version gate, server-url and login flow + +**Files:** +- Create: `flutter_client/lib/shared/routing.dart` +- Create: `flutter_client/lib/shared/widgets/version_gate.dart` +- Modify: `flutter_client/lib/app.dart` + +- [ ] **Step 11.1: Create `lib/shared/widgets/version_gate.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../../api/client.dart'; +import '../../api/endpoints/health.dart'; +import '../../auth/auth_provider.dart'; + +final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async { + final url = await ref.watch(serverUrlProvider.future); + if (url == null) return _VersionResult.skipped; + final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null); + final body = await HealthApi(dio).check(); + final min = body['min_client_version']; + if (min == null || min.isEmpty) return _VersionResult.skipped; + final info = await PackageInfo.fromPlatform(); + final mine = Version.parse(info.version); + final required = Version.parse(min); + return mine < required ? _VersionResult.tooOld : _VersionResult.ok; +}); + +enum _VersionResult { ok, tooOld, skipped } + +class VersionGate extends ConsumerWidget { + const VersionGate({required this.child, super.key}); + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref.watch(_versionCheckProvider).when( + data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child, + error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error + loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), + ); + } +} + +class _TooOldScreen extends StatelessWidget { + const _TooOldScreen(); + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Update required', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), + ), + SizedBox(height: 12), + Text( + 'This client is too old for that Minstrel server. Install the latest build to continue.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 11.2: Create `lib/shared/routing.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../auth/auth_provider.dart'; +import '../auth/login_screen.dart'; +import '../auth/server_url_screen.dart'; +import 'widgets/version_gate.dart'; + +GoRouter buildRouter(Ref ref) { + return GoRouter( + initialLocation: '/', + redirect: (ctx, state) async { + final url = await ref.read(serverUrlProvider.future); + if (url == null) return '/server-url'; + final user = await ref.read(authControllerProvider.future); + if (user == null && state.matchedLocation != '/login' && state.matchedLocation != '/server-url') { + return '/login'; + } + if (user != null && (state.matchedLocation == '/login' || state.matchedLocation == '/server-url')) { + return '/home'; + } + return null; + }, + routes: [ + GoRoute(path: '/', redirect: (_, __) => '/home'), + GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + ShellRoute( + builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), + routes: [ + GoRoute(path: '/home', builder: (_, __) => const _HomePlaceholder()), + ], + ), + ], + ); +} + +class _ShellWithPlayerBar extends StatelessWidget { + const _ShellWithPlayerBar({required this.child}); + final Widget child; + @override + Widget build(BuildContext context) { + // PlayerBar is wired in Task 18; this placeholder leaves room. + return Column( + children: [ + Expanded(child: child), + ], + ); + } +} + +class _HomePlaceholder extends StatelessWidget { + const _HomePlaceholder(); + @override + Widget build(BuildContext context) => + const Scaffold(body: Center(child: Text('Home — coming in Task 14'))); +} +``` + +- [ ] **Step 11.3: Wire router into the app** + +Replace `lib/app.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/routing.dart'; +import 'theme/theme_data.dart'; + +class MinstrelApp extends ConsumerWidget { + const MinstrelApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = buildRouter(ref); + return MaterialApp.router( + title: 'Minstrel', + theme: buildThemeData(), + routerConfig: router, + ); + } +} +``` + +- [ ] **Step 11.4: Update smoke test (existing `test/smoke_test.dart`)** + +Replace contents: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/app.dart'; + +void main() { + testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async { + await tester.pumpWidget(const ProviderScope(child: MinstrelApp())); + await tester.pumpAndSettle(); + expect(find.text('Connect to your Minstrel'), findsOneWidget); + }); +} +``` + +Note: this test reads from real flutter_secure_storage in widget tests. It is reliable on CI because each test starts with empty keychain. If it flakes locally, override `secureStorageProvider` with a fake. + +- [ ] **Step 11.5: Run tests** + +```bash +cd flutter_client && flutter test +``` + +Expected: PASS (auth/login_screen_test still passes; smoke_test now lands on server-url). + +- [ ] **Step 11.6: Commit** + +```bash +git add flutter_client/lib/shared/ flutter_client/lib/app.dart flutter_client/test/smoke_test.dart +git commit -F - <<'EOF' +feat(flutter): GoRouter shell + version gate + auth-aware redirects + +Cold launch flow: no server-url → /server-url. URL set, no token → +/login. Token present → /home with shell. VersionGate runs once when +the URL changes, hits /healthz, blocks if min_client_version > +package version. /home is a placeholder until Task 14. +EOF +``` + +--- + +### Task 12 — Library API endpoints + library providers + +**Files:** +- Create: `flutter_client/lib/api/endpoints/library.dart` +- Create: `flutter_client/lib/library/library_providers.dart` +- Create: `flutter_client/test/api/endpoints/library_test.dart` + +- [ ] **Step 12.1: Create `lib/api/endpoints/library.dart`** + +```dart +import 'package:dio/dio.dart'; + +import '../../models/album.dart'; +import '../../models/artist.dart'; +import '../../models/home_data.dart'; +import '../../models/track.dart'; + +class LibraryApi { + LibraryApi(this._dio); + final Dio _dio; + + Future getHome() async { + final r = await _dio.get>('/api/home'); + return HomeData.fromJson(r.data ?? const {}); + } + + Future getArtist(String id) async { + final r = await _dio.get>('/api/artists/$id'); + return ArtistRef.fromJson(r.data ?? const {}); + } + + Future> getArtistAlbums(String id) async { + final r = await _dio.get>('/api/artists/$id'); + final raw = (r.data?['albums'] as List?) ?? const []; + return raw + .map((e) => AlbumRef.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future> getArtistTracks(String id) async { + final r = await _dio.get>('/api/artists/$id/tracks'); + final raw = (r.data?['tracks'] as List?) ?? const []; + return raw + .map((e) => TrackRef.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future<({AlbumRef album, List tracks})> getAlbum(String id) async { + final r = await _dio.get>('/api/albums/$id'); + final body = r.data ?? const {}; + final tracks = ((body['tracks'] as List?) ?? const []) + .map((e) => TrackRef.fromJson((e as Map).cast())) + .toList(growable: false); + return (album: AlbumRef.fromJson(body), tracks: tracks); + } +} +``` + +- [ ] **Step 12.2: Verify the actual server response shapes match these helpers** + +Run: `grep -nA5 "handleGetArtist\b\|handleGetArtistTracks\|handleGetAlbum" /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/internal/api/library.go /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/internal/api/library_albums.go 2>/dev/null | head -60` +Expected: handler shapes match — adjust the parser if a field is named differently (e.g. `tracks` vs `track_list`). If there's a drift, update the parsers; the rest of the plan assumes the names in `lib/api/endpoints/library.dart`. + +- [ ] **Step 12.3: Create `lib/library/library_providers.dart`** + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/client.dart'; +import '../api/endpoints/library.dart'; +import '../auth/auth_provider.dart'; +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/home_data.dart'; +import '../models/track.dart'; + +final dioProvider = FutureProvider((ref) async { + final url = await ref.watch(serverUrlProvider.future); + if (url == null) throw StateError('no server URL set'); + final storage = ref.watch(secureStorageProvider); + return ApiClient.buildDio( + baseUrl: url, + tokenResolver: () async => storage.read(key: 'session_token'), + ); +}); + +final libraryApiProvider = FutureProvider((ref) async { + return LibraryApi(await ref.watch(dioProvider.future)); +}); + +final homeProvider = FutureProvider((ref) async { + return (await ref.watch(libraryApiProvider.future)).getHome(); +}); + +final artistProvider = FutureProvider.family((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtist(id); +}); + +final artistAlbumsProvider = FutureProvider.family, String>((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id); +}); + +final artistTracksProvider = FutureProvider.family, String>((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id); +}); + +final albumProvider = + FutureProvider.family<({AlbumRef album, List tracks}), String>( + (ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getAlbum(id); + }, +); +``` + +- [ ] **Step 12.4: Write parser tests for LibraryApi** + +`flutter_client/test/api/endpoints/library_test.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:minstrel/api/endpoints/library.dart'; + +class _StubAdapter extends HttpClientAdapter { + _StubAdapter(this._body); + final Map _body; + @override + Future fetch(RequestOptions options, Stream>? requestStream, Future? cancelFuture) async { + return ResponseBody.fromString( + jsonEncode(_body), + 200, + headers: const {Headers.contentTypeHeader: ['application/json']}, + ); + } + @override + void close({bool force = false}) {} +} + +import 'dart:convert'; + +void main() { + Dio dioWith(Map body) { + final d = Dio(BaseOptions(baseUrl: 'http://x')); + d.httpClientAdapter = _StubAdapter(body); + return d; + } + + test('getAlbum parses album + tracks list', () async { + final api = LibraryApi(dioWith({ + 'id': 'al-1', + 'title': 'Geogaddi', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + 'tracks': [ + { + 'id': 't-1', 'title': 'Music Is Math', + 'album_id': 'al-1', 'album_title': 'Geogaddi', + 'artist_id': 'art-1', 'artist_name': 'Boards of Canada', + 'duration_ms': 312000, 'track_number': 4, + } + ], + })); + final r = await api.getAlbum('al-1'); + expect(r.album.title, 'Geogaddi'); + expect(r.tracks.single.title, 'Music Is Math'); + }); +} +``` + +- [ ] **Step 12.5: Run tests** + +```bash +cd flutter_client && flutter test test/api/ +``` + +Expected: PASS. + +- [ ] **Step 12.6: Commit** + +```bash +git add flutter_client/lib/api/endpoints/library.dart flutter_client/lib/library/library_providers.dart flutter_client/test/api/endpoints/library_test.dart +git commit -F - <<'EOF' +feat(flutter/library): API endpoints + Riverpod providers + +LibraryApi wraps GET /api/home, /api/artists/{id}(/tracks), +/api/albums/{id}. dioProvider builds an authenticated dio (token +resolver reads session_token from secure storage on every request). +homeProvider, artistProvider(id), albumProvider(id) sit on top. +EOF +``` + +--- + +### Task 13 — Library widgets (HorizontalScrollRow, ArtistCard, AlbumCard, TrackRow) + +**Files:** +- Create: `flutter_client/lib/library/widgets/horizontal_scroll_row.dart` +- Create: `flutter_client/lib/library/widgets/artist_card.dart` +- Create: `flutter_client/lib/library/widgets/album_card.dart` +- Create: `flutter_client/lib/library/widgets/track_row.dart` + +- [ ] **Step 13.1: Create `widgets/horizontal_scroll_row.dart`** + +```dart +import 'package:flutter/material.dart'; + +import '../../theme/theme_extension.dart'; + +/// Mirrors the web HorizontalScrollRow: a labeled section that scrolls +/// horizontally. Multi-row sections share one `controller` so they +/// scroll together. +class HorizontalScrollRow extends StatelessWidget { + const HorizontalScrollRow({ + required this.title, + required this.children, + this.height = 200, + this.controller, + super.key, + }); + + final String title; + final List children; + final double height; + final ScrollController? controller; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + title, + style: TextStyle( + fontFamily: fs.display.fontFamily, + fontSize: 18, + color: fs.parchment, + ), + ), + ), + SizedBox( + height: height, + child: ListView( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: children, + ), + ), + ]); + } +} +``` + +- [ ] **Step 13.2: Create `widgets/artist_card.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/artist.dart'; +import '../../theme/theme_extension.dart'; + +class ArtistCard extends StatelessWidget { + const ArtistCard({required this.artist, required this.onTap, super.key}); + final ArtistRef artist; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 140, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClipOval( + child: Container( + width: 124, height: 124, color: fs.slate, + child: artist.coverArtUrl == null + ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) + : Image.network(artist.coverArtUrl!, fit: BoxFit.cover), + ), + ), + const SizedBox(height: 8), + Text(artist.name, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14)), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 13.3: Create `widgets/album_card.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/album.dart'; +import '../../theme/theme_extension.dart'; + +class AlbumCard extends StatelessWidget { + const AlbumCard({required this.album, required this.onTap, super.key}); + final AlbumRef album; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 140, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 124, height: 124, color: fs.slate, + child: album.coverArtUrl == null + ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) + : Image.network(album.coverArtUrl!, fit: BoxFit.cover), + ), + ), + const SizedBox(height: 8), + Text(album.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14)), + Text(album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12)), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 13.4: Create `widgets/track_row.dart`** + +```dart +import 'package:flutter/material.dart'; + +import '../../models/track.dart'; +import '../../theme/theme_extension.dart'; + +class TrackRow extends StatelessWidget { + const TrackRow({ + required this.track, + required this.onTap, + this.trailing, + super.key, + }); + final TrackRef track; + final VoidCallback onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final mins = (track.durationMs ~/ 60000).toString().padLeft(2, '0'); + final secs = ((track.durationMs % 60000) ~/ 1000).toString().padLeft(2, '0'); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + if (track.trackNumber != null) + SizedBox( + width: 28, + child: Text( + track.trackNumber.toString(), + style: TextStyle(color: fs.ash, fontSize: 13), + ), + ), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14)), + Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12)), + ]), + ), + Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), + if (trailing != null) Padding( + padding: const EdgeInsets.only(left: 8), + child: trailing!, + ), + ]), + ), + ); + } +} +``` + +- [ ] **Step 13.5: Smoke test the widgets** + +`flutter_client/test/library/widgets_smoke_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/library/widgets/album_card.dart'; +import 'package:minstrel/library/widgets/track_row.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/track.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + testWidgets('AlbumCard renders title and artist', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: Scaffold( + body: AlbumCard( + album: const AlbumRef( + id: 'a', title: 'Geogaddi', + artistId: 'x', artistName: 'Boards of Canada', + ), + onTap: () {}, + ), + ), + )); + expect(find.text('Geogaddi'), findsOneWidget); + expect(find.text('Boards of Canada'), findsOneWidget); + }); + + testWidgets('TrackRow shows mm:ss duration', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: Scaffold( + body: TrackRow( + track: const TrackRef( + id: 't', title: 'Roygbiv', albumId: 'a', albumTitle: 'Music', + artistId: 'x', artistName: 'BoC', durationMs: 137000, trackNumber: 4, + ), + onTap: () {}, + ), + ), + )); + expect(find.text('02:17'), findsOneWidget); + }); +} +``` + +- [ ] **Step 13.6: Run tests** + +```bash +cd flutter_client && flutter test test/library/ +``` + +Expected: PASS. + +- [ ] **Step 13.7: Commit** + +```bash +git add flutter_client/lib/library/widgets/ flutter_client/test/library/ +git commit -F - <<'EOF' +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 cover_art_url is missing. +EOF +``` + +--- + +### Task 14 — Home screen + +**Files:** +- Create: `flutter_client/lib/library/home_screen.dart` +- Create: `flutter_client/test/library/home_screen_test.dart` +- Modify: `flutter_client/lib/shared/routing.dart` — replace `_HomePlaceholder` + +- [ ] **Step 14.1: Create `lib/library/home_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/track.dart'; +import '../theme/theme_extension.dart'; +import 'library_providers.dart'; +import 'widgets/album_card.dart'; +import 'widgets/artist_card.dart'; +import 'widgets/horizontal_scroll_row.dart'; +import 'widgets/track_row.dart'; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return Scaffold( + backgroundColor: fs.obsidian, + body: SafeArea( + child: ref.watch(homeProvider).when( + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + loading: () => const Center(child: CircularProgressIndicator()), + data: (h) => RefreshIndicator( + onRefresh: () async => ref.refresh(homeProvider.future), + child: ListView(children: [ + _albumsRow(context, 'Recently added', h.recentlyAddedAlbums), + _albumsRow(context, 'Rediscover', h.rediscoverAlbums), + _artistsRow(context, 'Rediscover artists', h.rediscoverArtists), + _tracksRow(context, ref, 'Most played', h.mostPlayedTracks), + _artistsRow(context, 'Last played artists', h.lastPlayedArtists), + const SizedBox(height: 96), + ]), + ), + ), + ), + ); + } + + Widget _albumsRow(BuildContext ctx, String title, List albums) => + HorizontalScrollRow( + title: title, + children: [ + for (final a in albums) + AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')), + ], + ); + + Widget _artistsRow(BuildContext ctx, String title, List artists) => + HorizontalScrollRow( + title: title, + children: [ + for (final a in artists) + ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')), + ], + ); + + Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List tracks) => + HorizontalScrollRow( + title: title, + height: 64, + children: [ + for (final t in tracks) + SizedBox( + width: 280, + child: TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }), + ), + ], + ); +} +``` + +- [ ] **Step 14.2: Wire HomeScreen into the router** + +In `lib/shared/routing.dart`, replace `_HomePlaceholder` references: + +```dart +import '../library/home_screen.dart'; +// ... +GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), +``` + +Delete the now-unused `_HomePlaceholder` class. + +- [ ] **Step 14.3: Write a screen test that overrides `homeProvider`** + +`flutter_client/test/library/home_screen_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/library/home_screen.dart'; +import 'package:minstrel/library/library_providers.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/models/home_data.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + testWidgets('home renders sections with rows', (tester) async { + final fixture = HomeData( + recentlyAddedAlbums: const [ + AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'), + ], + rediscoverAlbums: const [], + rediscoverArtists: const [ + ArtistRef(id: 'r', name: 'Aphex Twin'), + ], + mostPlayedTracks: const [], + lastPlayedArtists: const [], + ); + await tester.pumpWidget(ProviderScope( + overrides: [ + homeProvider.overrideWith((ref) async => fixture), + ], + child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), + )); + await tester.pumpAndSettle(); + expect(find.text('Recently added'), findsOneWidget); + expect(find.text('Geogaddi'), findsOneWidget); + expect(find.text('Aphex Twin'), findsOneWidget); + }); +} +``` + +- [ ] **Step 14.4: Run test** + +```bash +cd flutter_client && flutter test test/library/home_screen_test.dart +``` + +Expected: PASS. + +- [ ] **Step 14.5: Commit** + +```bash +git add flutter_client/lib/library/home_screen.dart flutter_client/lib/shared/routing.dart flutter_client/test/library/home_screen_test.dart +git commit -F - <<'EOF' +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. +EOF +``` + +--- + +### Task 15 — Artist + Album detail screens + +**Files:** +- Create: `flutter_client/lib/library/artist_detail_screen.dart` +- Create: `flutter_client/lib/library/album_detail_screen.dart` +- Create: `flutter_client/test/library/artist_detail_screen_test.dart` +- Create: `flutter_client/test/library/album_detail_screen_test.dart` +- Modify: `flutter_client/lib/shared/routing.dart` — add detail routes + +- [ ] **Step 15.1: Create `lib/library/artist_detail_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../models/album.dart'; +import '../theme/theme_extension.dart'; +import 'library_providers.dart'; +import 'widgets/album_card.dart'; + +class ArtistDetailScreen extends ConsumerWidget { + const ArtistDetailScreen({required this.id, super.key}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final artist = ref.watch(artistProvider(id)); + final albums = ref.watch(artistAlbumsProvider(id)); + + return Scaffold( + appBar: AppBar(), + backgroundColor: fs.obsidian, + body: artist.when( + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + loading: () => const Center(child: CircularProgressIndicator()), + data: (a) => ListView(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + Container( + width: 96, height: 96, + decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + a.name, + style: TextStyle( + color: fs.parchment, + fontFamily: fs.display.fontFamily, + fontSize: 24, + ), + ), + ), + Container( + width: 48, height: 48, + decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), + child: IconButton( + icon: Icon(Icons.play_arrow, color: fs.parchment), + onPressed: () { /* play wired in Task 18 */ }, + ), + ), + // LikeButton goes here in Task 16; kept simple now. + ]), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Albums', style: TextStyle(fontSize: 16)), + ), + albums.when( + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), + data: (list) => GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.8, + ), + itemCount: list.length, + itemBuilder: (_, i) { + final AlbumRef album = list[i]; + return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}')); + }, + ), + ), + ]), + ), + ); + } +} +``` + +- [ ] **Step 15.2: Create `lib/library/album_detail_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../theme/theme_extension.dart'; +import 'library_providers.dart'; +import 'widgets/track_row.dart'; + +class AlbumDetailScreen extends ConsumerWidget { + const AlbumDetailScreen({required this.id, super.key}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar(), + backgroundColor: fs.obsidian, + body: ref.watch(albumProvider(id)).when( + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + loading: () => const Center(child: CircularProgressIndicator()), + data: (r) => ListView(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + Container(width: 96, height: 96, color: fs.slate), + const SizedBox(width: 16), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r.album.title, style: TextStyle( + color: fs.parchment, + fontFamily: fs.display.fontFamily, + fontSize: 22, + )), + Text(r.album.artistName, style: TextStyle(color: fs.ash)), + ], + )), + Container( + width: 48, height: 48, + decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), + child: IconButton( + icon: Icon(Icons.play_arrow, color: fs.parchment), + onPressed: () { /* play wired in Task 18 */ }, + ), + ), + ]), + ), + for (final t in r.tracks) + TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }), + ]), + ), + ); + } +} +``` + +- [ ] **Step 15.3: Wire detail routes into the router** + +In `lib/shared/routing.dart`, inside the ShellRoute's `routes:` list: + +```dart +import '../library/album_detail_screen.dart'; +import '../library/artist_detail_screen.dart'; + +// ... +routes: [ + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute( + path: '/artists/:id', + builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!), + ), + GoRoute( + path: '/albums/:id', + builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), + ), +], +``` + +- [ ] **Step 15.4: Smoke tests for both screens** + +`flutter_client/test/library/artist_detail_screen_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/library/artist_detail_screen.dart'; +import 'package:minstrel/library/library_providers.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + testWidgets('renders artist name and Albums header', (tester) async { + await tester.pumpWidget(ProviderScope( + overrides: [ + artistProvider('a-1').overrideWith((ref) async => const ArtistRef(id: 'a-1', name: 'Aphex Twin')), + artistAlbumsProvider('a-1').overrideWith((ref) async => const []), + ], + child: MaterialApp(theme: buildThemeData(), home: const ArtistDetailScreen(id: 'a-1')), + )); + await tester.pumpAndSettle(); + expect(find.text('Aphex Twin'), findsOneWidget); + expect(find.text('Albums'), findsOneWidget); + }); +} +``` + +`flutter_client/test/library/album_detail_screen_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/library/album_detail_screen.dart'; +import 'package:minstrel/library/library_providers.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/track.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + testWidgets('renders album header + track list', (tester) async { + await tester.pumpWidget(ProviderScope( + overrides: [ + albumProvider('al-1').overrideWith((ref) async => ( + album: const AlbumRef(id: 'al-1', title: 'Drukqs', artistId: 'a-1', artistName: 'Aphex Twin'), + tracks: const [ + TrackRef(id: 't-1', title: 'Avril 14th', albumId: 'al-1', albumTitle: 'Drukqs', + artistId: 'a-1', artistName: 'Aphex Twin', durationMs: 121000, trackNumber: 4), + ], + )), + ], + child: MaterialApp(theme: buildThemeData(), home: const AlbumDetailScreen(id: 'al-1')), + )); + await tester.pumpAndSettle(); + expect(find.text('Drukqs'), findsOneWidget); + expect(find.text('Avril 14th'), findsOneWidget); + }); +} +``` + +- [ ] **Step 15.5: Run tests** + +```bash +cd flutter_client && flutter test test/library/ +``` + +Expected: PASS. + +- [ ] **Step 15.6: Commit** + +```bash +git add flutter_client/lib/library/artist_detail_screen.dart flutter_client/lib/library/album_detail_screen.dart flutter_client/lib/shared/routing.dart flutter_client/test/library/artist_detail_screen_test.dart flutter_client/test/library/album_detail_screen_test.dart +git commit -F - <<'EOF' +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. +EOF +``` + +--- + +### Task 16 — Likes API + LikesProvider + LikeButton + wiring + +**Files:** +- Create: `flutter_client/lib/api/endpoints/likes.dart` +- Create: `flutter_client/lib/likes/likes_provider.dart` +- Create: `flutter_client/lib/likes/like_button.dart` +- Create: `flutter_client/test/likes/like_button_test.dart` +- Modify: `flutter_client/lib/library/widgets/track_row.dart` — wire LikeButton +- Modify: `flutter_client/lib/library/artist_detail_screen.dart` — wire LikeButton +- Modify: `flutter_client/lib/library/album_detail_screen.dart` — wire LikeButton + +- [ ] **Step 16.1: Create `lib/api/endpoints/likes.dart`** + +```dart +import 'package:dio/dio.dart'; + +enum LikeKind { artist, album, track } + +extension on LikeKind { + String get path => switch (this) { + LikeKind.artist => 'artists', + LikeKind.album => 'albums', + LikeKind.track => 'tracks', + }; +} + +class LikesApi { + LikesApi(this._dio); + final Dio _dio; + + Future like(LikeKind kind, String id) async { + await _dio.post('/api/likes/${kind.path}/$id'); + } + + Future unlike(LikeKind kind, String id) async { + await _dio.delete('/api/likes/${kind.path}/$id'); + } + + /// Returns a payload of {artist_ids, album_ids, track_ids} the user has liked. + Future<({Set artists, Set albums, Set tracks})> ids() async { + final r = await _dio.get>('/api/likes/ids'); + final body = r.data ?? const {}; + Set set(String key) => + ((body[key] as List?) ?? const []).map((e) => e.toString()).toSet(); + return ( + artists: set('artist_ids'), + albums: set('album_ids'), + tracks: set('track_ids'), + ); + } +} +``` + +- [ ] **Step 16.2: Create `lib/likes/likes_provider.dart`** + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/likes.dart'; +import '../library/library_providers.dart'; + +final likesApiProvider = FutureProvider((ref) async { + return LikesApi(await ref.watch(dioProvider.future)); +}); + +class LikedIds { + const LikedIds({required this.artists, required this.albums, required this.tracks}); + final Set artists; + final Set albums; + final Set tracks; + + bool has(LikeKind kind, String id) => switch (kind) { + LikeKind.artist => artists.contains(id), + LikeKind.album => albums.contains(id), + LikeKind.track => tracks.contains(id), + }; +} + +class LikedIdsController extends AsyncNotifier { + @override + Future build() async { + final api = await ref.watch(likesApiProvider.future); + final r = await api.ids(); + return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks); + } + + Future toggle(LikeKind kind, String id) async { + final api = await ref.read(likesApiProvider.future); + final current = state.value ?? const LikedIds(artists: {}, albums: {}, tracks: {}); + + final isLiked = current.has(kind, id); + final optimistic = _flip(current, kind, id); + state = AsyncData(optimistic); + + try { + if (isLiked) { + await api.unlike(kind, id); + } else { + await api.like(kind, id); + } + } catch (e, st) { + // Rollback on failure so the user sees the truth. + state = AsyncData(current); + // Re-throw so callers can surface a toast if they care. + Error.throwWithStackTrace(e, st); + } + } + + LikedIds _flip(LikedIds before, LikeKind kind, String id) { + Set swap(Set s) { + final next = {...s}; + if (s.contains(id)) { + next.remove(id); + } else { + next.add(id); + } + return next; + } + return switch (kind) { + LikeKind.artist => LikedIds(artists: swap(before.artists), albums: before.albums, tracks: before.tracks), + LikeKind.album => LikedIds(artists: before.artists, albums: swap(before.albums), tracks: before.tracks), + LikeKind.track => LikedIds(artists: before.artists, albums: before.albums, tracks: swap(before.tracks)), + }; + } +} + +final likedIdsProvider = + AsyncNotifierProvider(LikedIdsController.new); +``` + +- [ ] **Step 16.3: Create `lib/likes/like_button.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/likes.dart'; +import '../theme/theme_extension.dart'; +import 'likes_provider.dart'; + +class LikeButton extends ConsumerWidget { + const LikeButton({required this.kind, required this.id, this.size = 22, super.key}); + final LikeKind kind; + final String id; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final state = ref.watch(likedIdsProvider); + final liked = state.maybeWhen( + data: (s) => s.has(kind, id), + orElse: () => false, + ); + return IconButton( + icon: Icon( + liked ? Icons.favorite : Icons.favorite_border, + color: liked ? fs.accent : fs.ash, + size: size, + ), + onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id), + ); + } +} +``` + +- [ ] **Step 16.4: Wire LikeButton into TrackRow** + +Modify `lib/library/widgets/track_row.dart`: + +```dart +// add import: +import '../../api/endpoints/likes.dart'; +import '../../likes/like_button.dart'; +``` + +Replace the TrackRow constructor + body so the trailing slot defaults to a LikeButton when an entity reference is provided. Simpler: leave `trailing` as-is and let callers pass `trailing: LikeButton(kind: LikeKind.track, id: track.id)`. (This keeps TrackRow agnostic of likes.) + +- [ ] **Step 16.5: Wire LikeButton into Artist + Album detail headers** + +In `lib/library/artist_detail_screen.dart`, replace the comment `// LikeButton goes here in Task 16` with: + +```dart +LikeButton(kind: LikeKind.artist, id: a.id, size: 28), +``` + +(Add the import: `import '../api/endpoints/likes.dart';` and `import '../likes/like_button.dart';`.) + +In `lib/library/album_detail_screen.dart`, after the existing play-button container, add the same pattern with `kind: LikeKind.album, id: r.album.id`. Also pass `trailing: LikeButton(kind: LikeKind.track, id: t.id)` to each `TrackRow` in the loop. + +- [ ] **Step 16.6: Write a test for optimistic toggle + rollback** + +`flutter_client/test/likes/like_button_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/endpoints/likes.dart'; +import 'package:minstrel/likes/like_button.dart'; +import 'package:minstrel/likes/likes_provider.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _ThrowingLikesApi implements LikesApi { + bool throwOnNext = false; + @override + Future like(LikeKind kind, String id) async { + if (throwOnNext) throw StateError('boom'); + } + @override + Future unlike(LikeKind kind, String id) async { + if (throwOnNext) throw StateError('boom'); + } + @override + Future<({Set artists, Set albums, Set tracks})> ids() async => + (artists: {}, albums: {}, tracks: {}); +} + +void main() { + testWidgets('tap toggles icon optimistically; rollback on error', (tester) async { + final api = _ThrowingLikesApi(); + final container = ProviderContainer(overrides: [ + likesApiProvider.overrideWith((ref) async => api), + ]); + addTearDown(container.dispose); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildThemeData(), + home: const Scaffold(body: LikeButton(kind: LikeKind.album, id: 'al-1')), + ), + )); + await tester.pumpAndSettle(); + + // First tap = like; succeeds. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Second tap = unlike; force failure → state rolls back to "liked". + api.throwOnNext = true; + final notifier = container.read(likedIdsProvider.notifier); + try { + await notifier.toggle(LikeKind.album, 'al-1'); + } catch (_) {/* expected */} + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); // rolled back + }); +} +``` + +- [ ] **Step 16.7: Run tests** + +```bash +cd flutter_client && flutter test +``` + +Expected: PASS. + +- [ ] **Step 16.8: Commit** + +```bash +git add flutter_client/lib/api/endpoints/likes.dart flutter_client/lib/likes/ flutter_client/lib/library/ flutter_client/test/likes/ +git commit -F - <<'EOF' +feat(flutter/likes): LikeButton with optimistic toggle + rollback + +LikedIdsController loads /api/likes/ids once and mutates the local set +on toggle. Failed mutations roll the set back so the icon never lies. +Wired into ArtistDetail header, AlbumDetail header, and per-track in +the album track list. +EOF +``` + +--- + +### Task 17 — Audio handler + player provider + +**Files:** +- Create: `flutter_client/lib/player/audio_handler.dart` +- Create: `flutter_client/lib/player/player_provider.dart` +- Create: `flutter_client/test/player/player_provider_test.dart` +- Modify: `flutter_client/lib/main.dart` — initialize audio_service +- Modify: `flutter_client/android/app/src/main/AndroidManifest.xml` — audio service intent +- Modify: `flutter_client/ios/Runner/Info.plist` — UIBackgroundModes audio + +- [ ] **Step 17.1: Create `lib/player/audio_handler.dart`** + +```dart +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; + +import '../models/track.dart'; + +class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { + MinstrelAudioHandler() { + _player.playbackEventStream.listen(_broadcastState); + } + + final AudioPlayer _player = AudioPlayer(); + String _baseUrl = ''; + String? _token; + + void configure({required String baseUrl, required String? token}) { + _baseUrl = baseUrl; + _token = token; + } + + Future setQueueFromTracks(List tracks, {int initialIndex = 0}) async { + final items = tracks.map(_toMediaItem).toList(); + queue.add(items); + if (items.isNotEmpty) mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]); + + final sources = tracks + .map((t) => AudioSource.uri( + Uri.parse('$_baseUrl/api/tracks/${t.id}/stream'), + headers: _token == null ? null : {'Authorization': 'Bearer $_token'}, + )) + .toList(); + + await _player.setAudioSource( + ConcatenatingAudioSource(children: sources), + initialIndex: initialIndex, + ); + } + + MediaItem _toMediaItem(TrackRef t) => MediaItem( + id: t.id, + title: t.title, + artist: t.artistName, + album: t.albumTitle, + duration: Duration(milliseconds: t.durationMs), + ); + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future seek(Duration p) => _player.seek(p); + + @override + Future skipToNext() => _player.seekToNext(); + + @override + Future skipToPrevious() => _player.seekToPrevious(); + + void _broadcastState(PlaybackEvent event) { + final playing = _player.playing; + playbackState.add(PlaybackState( + controls: [ + MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.skipToNext, + ], + systemActions: const {MediaAction.seek}, + processingState: switch (_player.processingState) { + ProcessingState.idle => AudioProcessingState.idle, + ProcessingState.loading => AudioProcessingState.loading, + ProcessingState.buffering => AudioProcessingState.buffering, + ProcessingState.ready => AudioProcessingState.ready, + ProcessingState.completed => AudioProcessingState.completed, + }, + playing: playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + )); + } +} +``` + +- [ ] **Step 17.2: Create `lib/player/player_provider.dart`** + +```dart +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../auth/auth_provider.dart'; +import '../models/track.dart'; +import 'audio_handler.dart'; + +final audioHandlerProvider = Provider((ref) { + throw UnimplementedError('overridden in main()'); +}); + +final playbackStateProvider = StreamProvider( + (ref) => ref.watch(audioHandlerProvider).playbackState, +); + +final mediaItemProvider = StreamProvider( + (ref) => ref.watch(audioHandlerProvider).mediaItem, +); + +final queueProvider = StreamProvider>( + (ref) => ref.watch(audioHandlerProvider).queue, +); + +class PlayerActions { + PlayerActions(this._ref); + final Ref _ref; + + Future playTracks(List tracks, {int initialIndex = 0}) async { + final url = await _ref.read(serverUrlProvider.future); + final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); + final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token); + await h.setQueueFromTracks(tracks, initialIndex: initialIndex); + await h.play(); + } +} + +final playerActionsProvider = Provider((ref) => PlayerActions(ref)); +``` + +- [ ] **Step 17.3: Initialize audio_service in `main.dart`** + +Replace `lib/main.dart`: + +```dart +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; +import 'player/audio_handler.dart'; +import 'player/player_provider.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final handler = await AudioService.init( + builder: () => MinstrelAudioHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.fabledsword.minstrel.audio', + androidNotificationChannelName: 'Minstrel playback', + androidNotificationOngoing: true, + ), + ); + runApp(ProviderScope( + overrides: [audioHandlerProvider.overrideWithValue(handler)], + child: const MinstrelApp(), + )); +} +``` + +- [ ] **Step 17.4: Add Android service permissions** + +Edit `flutter_client/android/app/src/main/AndroidManifest.xml`. Inside ``: + +```xml + + + + +``` + +Inside `` (alongside the existing ``): + +```xml + + + + + + + + + + +``` + +- [ ] **Step 17.5: Add iOS background mode** + +Edit `flutter_client/ios/Runner/Info.plist`. Inside the top-level ``: + +```xml +UIBackgroundModes + + audio + +``` + +- [ ] **Step 17.6: Smoke test the player provider (no real audio)** + +`flutter_client/test/player/player_provider_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/player/audio_handler.dart'; +import 'package:minstrel/player/player_provider.dart'; + +void main() { + test('audioHandlerProvider must be overridden — bare read throws', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(() => container.read(audioHandlerProvider), throwsA(isA())); + }); + + test('overridden audioHandlerProvider exposes playbackState stream', () { + final handler = MinstrelAudioHandler(); + final container = ProviderContainer(overrides: [ + audioHandlerProvider.overrideWithValue(handler), + ]); + addTearDown(container.dispose); + expect(container.read(audioHandlerProvider), same(handler)); + // playbackStateProvider is a StreamProvider; just touch it to make sure + // the binding doesn't blow up. + final sub = container.listen(playbackStateProvider, (_, __) {}); + sub.close(); + }); +} +``` + +- [ ] **Step 17.7: Run tests** + +```bash +cd flutter_client && flutter test test/player/ +``` + +Expected: PASS. + +- [ ] **Step 17.8: Commit** + +```bash +git add flutter_client/lib/player/ flutter_client/lib/main.dart flutter_client/android/app/src/main/AndroidManifest.xml flutter_client/ios/Runner/Info.plist flutter_client/test/player/ +git commit -F - <<'EOF' +feat(flutter/player): MinstrelAudioHandler + just_audio + audio_service + +audio_service runs the handler in a background isolate; UI watches +playbackState/mediaItem/queue streams through Riverpod. AndroidManifest +gets the foreground-service media-playback permission; Info.plist gets +UIBackgroundModes=audio. Bearer token attached to stream URLs via +just_audio's per-source headers. +EOF +``` + +--- + +### Task 18 — PlayerBar mini widget + tap-to-play wiring + +**Files:** +- Create: `flutter_client/lib/player/player_bar.dart` +- Modify: `flutter_client/lib/shared/routing.dart` — replace `_ShellWithPlayerBar` body with real PlayerBar +- Modify: `flutter_client/lib/library/home_screen.dart` — wire track-tap to playerActions +- Modify: `flutter_client/lib/library/album_detail_screen.dart` — wire track + album play +- Modify: `flutter_client/lib/library/artist_detail_screen.dart` — wire artist Play button + +- [ ] **Step 18.1: Create `lib/player/player_bar.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../theme/theme_extension.dart'; +import 'player_provider.dart'; + +class PlayerBar extends ConsumerWidget { + const PlayerBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final mediaItem = ref.watch(mediaItemProvider).valueOrNull; + final playback = ref.watch(playbackStateProvider).valueOrNull; + if (mediaItem == null) return const SizedBox.shrink(); + + return Material( + color: fs.iron, + child: InkWell( + onTap: () => context.push('/now-playing'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row(children: [ + Container(width: 48, height: 48, color: fs.slate), + const SizedBox(width: 12), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14)), + Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12)), + ], + )), + IconButton( + icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment), + onPressed: () { + final h = ref.read(audioHandlerProvider); + if (playback?.playing == true) { h.pause(); } else { h.play(); } + }, + ), + IconButton( + icon: Icon(Icons.skip_next, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToNext(), + ), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 18.2: Mount PlayerBar in the shell** + +In `lib/shared/routing.dart`, replace the `_ShellWithPlayerBar` class: + +```dart +import '../player/player_bar.dart'; + +class _ShellWithPlayerBar extends StatelessWidget { + const _ShellWithPlayerBar({required this.child}); + final Widget child; + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded(child: child), + const PlayerBar(), + ], + ); + } +} +``` + +- [ ] **Step 18.3: Wire track-tap on the home screen** + +In `lib/library/home_screen.dart`, change the track-tap handler in `_tracksRow`: + +```dart +SizedBox( + width: 280, + child: TrackRow( + track: t, + onTap: () => ref.read(playerActionsProvider).playTracks([t]), + ), +), +``` + +- [ ] **Step 18.4: Wire play actions on album detail** + +In `lib/library/album_detail_screen.dart`: + +- Album header play button: replace `onPressed: () { /* play wired in Task 18 */ }` with: + ```dart + onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks), + ``` +- Per-track tap: replace `onTap: () { /* play wired in Task 18 */ }` with: + ```dart + onTap: () { + final start = r.tracks.indexOf(t); + ref.read(playerActionsProvider).playTracks(r.tracks, initialIndex: start); + }, + ``` + +(Add `import 'package:flutter_riverpod/flutter_riverpod.dart';` and `import '../player/player_provider.dart';` at the top.) + +- [ ] **Step 18.5: Wire play action on artist detail** + +In `lib/library/artist_detail_screen.dart` artist header: + +```dart +import '../player/player_provider.dart'; +// ... +onPressed: () async { + final tracks = await ref.read(artistTracksProvider(id).future); + if (tracks.isEmpty) return; + final shuffled = [...tracks]..shuffle(); + ref.read(playerActionsProvider).playTracks(shuffled); +}, +``` + +- [ ] **Step 18.6: Run tests** + +```bash +cd flutter_client && flutter test +``` + +Expected: PASS (no new test, but the existing widget tests should continue to render with PlayerBar mounted because mediaItem is null and PlayerBar returns SizedBox.shrink()). + +- [ ] **Step 18.7: Commit** + +```bash +git add flutter_client/lib/player/player_bar.dart flutter_client/lib/shared/routing.dart flutter_client/lib/library/ +git commit -F - <<'EOF' +feat(flutter/player): mini PlayerBar in shell + tap-to-play wiring + +PlayerBar reads playbackState + mediaItem streams; hidden when no +queue. Home track-row tap, album play button, album track tap, artist +play button (shuffle) all route to playerActions.playTracks. Token ++ baseUrl forwarded to the audio handler before each new queue. +EOF +``` + +--- + +### Task 19 — NowPlaying screen + +**Files:** +- Create: `flutter_client/lib/player/now_playing_screen.dart` +- Modify: `flutter_client/lib/shared/routing.dart` — add `/now-playing` route + +- [ ] **Step 19.1: Create `lib/player/now_playing_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../theme/theme_extension.dart'; +import 'player_provider.dart'; + +class NowPlayingScreen extends ConsumerWidget { + const NowPlayingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final media = ref.watch(mediaItemProvider).valueOrNull; + final playback = ref.watch(playbackStateProvider).valueOrNull; + + if (media == null) { + return const Scaffold(body: Center(child: Text('Nothing playing.'))); + } + + final pos = playback?.updatePosition ?? Duration.zero; + final dur = media.duration ?? Duration.zero; + + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + leading: IconButton( + icon: Icon(Icons.expand_more, color: fs.parchment), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(children: [ + const Spacer(), + Container( + width: 280, height: 280, color: fs.slate, + ), + const SizedBox(height: 24), + Text(media.title, style: TextStyle(color: fs.parchment, fontSize: 22)), + Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)), + const SizedBox(height: 16), + Slider( + activeColor: fs.accent, + min: 0, + max: dur.inMilliseconds.toDouble().clamp(1, double.infinity), + value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()), + onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())), + ), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + IconButton( + icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32), + onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), + ), + IconButton( + iconSize: 56, + icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent), + onPressed: () { + final h = ref.read(audioHandlerProvider); + if (playback?.playing == true) { h.pause(); } else { h.play(); } + }, + ), + IconButton( + icon: Icon(Icons.skip_next, color: fs.parchment, size: 32), + onPressed: () => ref.read(audioHandlerProvider).skipToNext(), + ), + ]), + const Spacer(), + ]), + ), + ), + ); + } +} +``` + +- [ ] **Step 19.2: Add `/now-playing` route** + +In `lib/shared/routing.dart`, inside the ShellRoute's `routes:` list, append: + +```dart +import '../player/now_playing_screen.dart'; +// ... +GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), +``` + +- [ ] **Step 19.3: Verify build** + +```bash +cd flutter_client && flutter analyze && flutter test +``` + +Expected: clean. + +- [ ] **Step 19.4: Commit** + +```bash +git add flutter_client/lib/player/now_playing_screen.dart flutter_client/lib/shared/routing.dart +git commit -F - <<'EOF' +feat(flutter/player): NowPlaying full-screen view with scrubber + +Reached by tapping the PlayerBar. Reads media + playback streams; +seek dispatches through the audio handler. Swipe-down dismiss is +implemented via the AppBar back arrow (chevron_down icon). +EOF +``` + +--- + +### Task 20 — Connection error banner + 401 redirect interceptor + +**Files:** +- Create: `flutter_client/lib/shared/widgets/connection_error_banner.dart` +- Modify: `flutter_client/lib/api/client.dart` — add 401 → unauthenticated redirect handler +- Modify: `flutter_client/lib/library/home_screen.dart` — surface ConnectionErrorBanner on `connection_refused` + +- [ ] **Step 20.1: Create the banner widget** + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../theme/theme_extension.dart'; + +class ConnectionErrorBanner extends StatelessWidget { + const ConnectionErrorBanner({required this.onRetry, super.key}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Container( + color: fs.iron, + padding: const EdgeInsets.all(12), + child: Row(children: [ + Expanded( + child: Text( + "Couldn't reach the server.", + style: TextStyle(color: fs.parchment), + ), + ), + TextButton(onPressed: onRetry, child: const Text('Retry')), + TextButton( + onPressed: () => context.go('/server-url'), + child: const Text('Change URL'), + ), + ]), + ); + } +} +``` + +- [ ] **Step 20.2: Extend the dio client builder with a 401 handler** + +Modify `lib/api/client.dart` — the `buildDio` factory now takes an optional `on401`: + +```dart +typedef OnUnauthenticated = Future Function(); + +class ApiClient { + static Dio buildDio({ + required String baseUrl, + required TokenResolver tokenResolver, + OnUnauthenticated? on401, + }) { + final d = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 30), + contentType: Headers.jsonContentType, + responseType: ResponseType.json, + )); + d.interceptors.add(InterceptorsWrapper( + onRequest: (opts, h) async { + final t = await tokenResolver(); + if (t != null && t.isNotEmpty) opts.headers['Authorization'] = 'Bearer $t'; + h.next(opts); + }, + onError: (e, h) async { + if (e.response?.statusCode == 401 && on401 != null) { + await on401(); + } + h.next(e); + }, + )); + return d; + } +} +``` + +- [ ] **Step 20.3: Wire `on401` in the dio provider** + +Modify `lib/library/library_providers.dart`: + +```dart +final dioProvider = FutureProvider((ref) async { + final url = await ref.watch(serverUrlProvider.future); + if (url == null) throw StateError('no server URL set'); + final storage = ref.watch(secureStorageProvider); + return ApiClient.buildDio( + baseUrl: url, + tokenResolver: () async => storage.read(key: 'session_token'), + on401: () async => ref.read(authControllerProvider.notifier).clearSession(), + ); +}); +``` + +(Add `import '../auth/auth_provider.dart';` if not already imported.) + +- [ ] **Step 20.4: Surface the banner on `connection_refused`** + +In `lib/library/home_screen.dart`, replace the error branch of the `.when` with a banner-aware rendering: + +```dart +error: (e, _) { + final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; + if (code == 'connection_refused') { + return ConnectionErrorBanner( + onRetry: () => ref.refresh(homeProvider), + ); + } + return Center(child: Text('$e', style: TextStyle(color: fs.error))); +}, +``` + +(Add imports: `import 'package:dio/dio.dart';`, `import '../api/errors.dart';`, `import '../shared/widgets/connection_error_banner.dart';`.) + +- [ ] **Step 20.5: Run tests** + +```bash +cd flutter_client && flutter test +``` + +Expected: PASS. + +- [ ] **Step 20.6: Commit** + +```bash +git add flutter_client/lib/shared/widgets/connection_error_banner.dart flutter_client/lib/api/client.dart flutter_client/lib/library/library_providers.dart flutter_client/lib/library/home_screen.dart +git commit -F - <<'EOF' +feat(flutter): connection-error banner + 401-clears-session interceptor + +ApiClient.buildDio takes on401; the library's dio wires it to the +auth controller's clearSession. The router redirect already handles +the navigation away from authed screens once the session goes null. +HomeScreen surfaces the banner on connection_refused with Retry + +Change URL. +EOF +``` + +--- + +### Task 21 — Forgejo CI workflow + APK release + +**Files:** +- Create: `.forgejo/workflows/flutter.yml` + +- [ ] **Step 21.1: Create the workflow** + +```yaml +name: flutter + +on: + push: + branches: [dev, main] + paths: + - 'flutter_client/**' + - 'web/src/lib/styles/tokens.json' + - 'web/src/lib/styles/error-copy.json' + - 'web/static/placeholders/album-fallback.svg' + - '.forgejo/workflows/flutter.yml' + pull_request: + release: + types: [published] + +jobs: + analyze-test-build: + runs-on: ubuntu-latest + container: + image: ghcr.io/cirruslabs/flutter:3.24.5 + defaults: + run: + working-directory: flutter_client + steps: + - uses: actions/checkout@v4 + + - name: Sync shared assets + run: ./tool/sync_shared.sh + + - name: Verify generated tokens.dart matches JSON + run: | + dart run tool/gen_tokens.dart + if ! git diff --exit-code lib/theme/tokens.dart; then + echo "::error::lib/theme/tokens.dart is out of sync with shared/fabledsword.tokens.json" + echo "Run: cd flutter_client && dart run tool/gen_tokens.dart" + exit 1 + fi + + - name: Pub get + run: flutter pub get + + - name: Analyze + run: flutter analyze --fatal-infos + + - name: Test + run: flutter test --reporter compact + + - name: Build debug APK + if: github.event_name == 'push' + run: flutter build apk --debug + + - name: Build release APK + if: github.event_name == 'release' + run: flutter build apk --release + + - name: Upload debug APK artifact + if: github.event_name == 'push' + uses: forgejo/upload-artifact@v3 + with: + name: minstrel-debug-${{ github.sha }}.apk + path: flutter_client/build/app/outputs/flutter-apk/app-debug.apk + + - name: Attach release APK to release + if: github.event_name == 'release' + run: | + curl -fsSL \ + -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ + -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \ + "${{ github.event.release.upload_url }}" +``` + +- [ ] **Step 21.2: Confirm Forgejo Actions runner picks it up** + +After committing, push to a branch and check Forgejo Actions in the web UI. Expected: workflow runs, all steps green on a clean checkout. + +- [ ] **Step 21.3: Commit** + +```bash +git add .forgejo/workflows/flutter.yml +git commit -F - <<'EOF' +ci(flutter): Forgejo workflow — sync, analyze, test, build APK + +Triggers on push to dev/main, pull_request, and release. Verifies +that lib/theme/tokens.dart matches shared/fabledsword.tokens.json +(catches drift between the JSON source-of-truth and the committed +generated file). Uploads a debug APK on every push and attaches the +release APK to the Forgejo release on tag. +EOF +``` + +--- + +### Task 22 — README + slice ship checklist + +**Files:** +- Create: `flutter_client/README.md` + +- [ ] **Step 22.1: Write the README** + +```markdown +# Minstrel mobile client + +Flutter (iOS + Android) sibling of the SvelteKit web SPA. v1 first +slice: scaffold, auth, library browse (home / artist / album), likes, +player with background audio + lock-screen controls. + +## Setup + +```bash +cd flutter_client +flutter pub get +./tool/sync_shared.sh # copy shared tokens, error-copy, placeholders from web/ +dart run tool/gen_tokens.dart +flutter run +``` + +## Project layout + +See `docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md` §3. + +## CI + +`.forgejo/workflows/flutter.yml` runs analyze + test + build on every +push. Debug APKs upload as artifacts; release APKs attach to the +Forgejo release on tag. + +## Versioning + +`pubspec.yaml` `version` mirrors the server tag (e.g. server `v0.1.0` +→ Flutter `0.1.0+1`). The server's `/healthz` returns +`min_client_version`; old clients show an Update Required modal and +refuse to operate. + +## Distribution + +- **Android:** APK on the Forgejo release page. No Play Store for v1. +- **iOS:** TestFlight on tag (manual upload v1). + +## Out-of-scope for slice 1 + +Search, discover, requests, settings beyond server URL, admin, +listening history, playlists, offline cache (#357). Those land in +subsequent slices with their own brainstorm/spec/plan cycles. +``` + +- [ ] **Step 22.2: Commit** + +```bash +git add flutter_client/README.md +git commit -F - <<'EOF' +docs(flutter): README for setup, layout, CI, distribution + +Pointers to the spec, CI workflow, and the v1 slice-1 scope so a +fresh contributor knows what's in slice 1 vs. what's deferred. +EOF +``` + +--- + +## Self-review + +1. **Spec coverage check:** + - §2 Goals — all in tasks 1–22. + - §3 Architecture — Tasks 4–11, 17 cover scaffold, layering, auth flow, audio. + - §4 Theme tokens — Tasks 1, 5, 6. + - §5 Screens — Tasks 14 (home), 15 (artist + album), 18–19 (player + nowplaying), 9–10 (auth). + - §6 Backend touch-points — Task 3 (`/healthz` `min_client_version`); login token already in body, so no change needed. + - §7 Error handling — Tasks 7 (ApiError), 11 (version gate), 20 (banner + 401 + stream errors covered by player surface in Task 18 — note: dedicated stream-error banner in player UI is intentionally elided to keep scope tight; just_audio's playback errors propagate through `playbackState.processingState` and the existing PlayerBar reflects them; if a richer banner is needed it lands as a hotfix in slice 2). + - §8 Testing — every screen has a smoke test; api/auth/likes/player providers have unit tests. + - §9 Distribution — Task 21. + +2. **Placeholder scan:** No "TBD"/"TODO" markers in the plan. Comments inside generated code (e.g. `/* play wired in Task 18 */`) are removed in Task 18 itself. + +3. **Type consistency:** `LikeKind`, `LikedIds`, `PlayerActions`, `MinstrelAudioHandler`, `FabledSwordTheme`, `ApiError`, `TokenResolver`, `OnUnauthenticated` all consistent across tasks. + +4. **One spec deviation noted:** spec §6 mentions a possible "Bearer-auth login response" backend touch-point. Inspection in this plan-writing pass confirmed `LoginResponse` already includes the token in the body (`internal/api/auth.go:115`), so the touch-point is unnecessary. Plan does not include it; documented here for reviewers.