# 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.