Files
minstrel/docs/superpowers/plans/2026-05-02-m7-flutter-mobile-foundation.md
T
bvandeusen 261c2c4301 docs(m7): implementation plan for Flutter mobile foundation + first slice
22 tasks covering: shared design-tokens source-of-truth (web side),
error-copy JSON extraction, server /healthz min_client_version,
Flutter scaffold + Riverpod/dio/just_audio/audio_service stack, auth
flow with secure storage + version gate, library browse (home, artist,
album) with cards/rows synced to FabledSword tokens, likes with
optimistic toggle + rollback, audio handler with lock-screen +
notification controls, mini PlayerBar in shell + NowPlaying, error
surfacing (connection banner + 401-clears-session), Forgejo CI with
analyze + test + APK build + release attachment.
2026-05-02 14:26:25 -04:00

4132 lines
128 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, string>` 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<Record<string, string>> = 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<String, dynamic>;
final colors = (tokens['colors'] as Map).cast<String, String>();
final radii = (tokens['radii'] as Map).cast<String, String>();
final fonts = (tokens['fonts'] as Map).cast<String, String>();
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<FabledSwordTheme>()!;
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<FabledSwordTheme> {
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<FabledSwordTheme>? 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: <ThemeExtension<dynamic>>[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<FabledSwordTheme>().
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<String, dynamic>) {
final field = data['error'];
if (field is String) {
return ApiError(code: field, message: field, status: response?.statusCode ?? 0);
}
if (field is Map<String, dynamic>) {
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<String, String> _table;
static ErrorCopy? _instance;
static Future<ErrorCopy> load() async {
if (_instance != null) return _instance!;
final raw = await rootBundle.loadString('assets/error-copy.json');
final m = (jsonDecode(raw) as Map).cast<String, dynamic>().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<String, dynamic>? captured;
d.options.headers['X-Test'] = 'baseline';
d.interceptors.add(
InterceptorsWrapper(onRequest: (opts, h) {
captured = Map<String, dynamic>.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<String, dynamic>? captured;
d.interceptors.add(
InterceptorsWrapper(onRequest: (opts, h) {
captured = Map<String, dynamic>.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<String?> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<AlbumRef> recentlyAddedAlbums;
final List<AlbumRef> rediscoverAlbums;
final List<ArtistRef> rediscoverArtists;
final List<TrackRef> mostPlayedTracks;
final List<ArtistRef> lastPlayedArtists;
factory HomeData.fromJson(Map<String, dynamic> 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<T> _list<T>(
Map<String, dynamic> j,
String key,
T Function(Map<String, dynamic>) parse,
) {
final raw = j[key] as List? ?? const [];
return raw.map((e) => parse((e as Map).cast<String, dynamic>())).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<Map<String, String>> check() async {
final r = await _dio.get<Map<String, dynamic>>('/healthz');
return (r.data ?? const <String, dynamic>{})
.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 = <String, String>{};
@override
Future<String?> read({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WindowsOptions? wOptions, WebOptions? webOptions, MacOsOptions? mOptions}) async => _m[key];
@override
Future<void> 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<void> 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<FlutterSecureStorage>(
(ref) => const FlutterSecureStorage(),
);
final serverUrlProvider = FutureProvider<String?>((ref) async {
return ref.watch(secureStorageProvider).read(key: _kServerUrl);
});
final sessionTokenProvider = FutureProvider<String?>((ref) async {
return ref.watch(secureStorageProvider).read(key: _kSessionToken);
});
class AuthController extends AsyncNotifier<User?> {
late FlutterSecureStorage _storage;
@override
Future<User?> 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<String, dynamic>);
}
Future<void> setServerUrl(String url) async {
await _storage.write(key: _kServerUrl, value: url);
ref.invalidate(serverUrlProvider);
}
Future<void> 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<String, dynamic>));
}
Future<void> clearSession() async {
await _storage.delete(key: _kSessionToken);
await _storage.delete(key: _kCurrentUser);
ref.invalidate(sessionTokenProvider);
state = const AsyncData(null);
}
}
final authControllerProvider =
AsyncNotifierProvider<AuthController, User?>(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<ServerUrlScreen> createState() => _ServerUrlScreenState();
}
class _ServerUrlScreenState extends ConsumerState<ServerUrlScreen> {
final _ctrl = TextEditingController();
bool _busy = false;
String? _error;
Future<void> _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<FabledSwordTheme>()!;
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<Map<String, dynamic>>(
'/api/auth/login',
data: {'username': username, 'password': password},
);
final body = r.data!;
final userMap = (body['user'] as Map).cast<String, dynamic>();
return (
token: body['token'] as String,
user: User.fromJson(userMap),
rawUserJson: jsonEncode(userMap),
);
}
Future<void> logout() async {
await _dio.post<void>('/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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
String? _error;
Future<void> _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<FabledSwordTheme>()!;
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<HomeData> getHome() async {
final r = await _dio.get<Map<String, dynamic>>('/api/home');
return HomeData.fromJson(r.data ?? const {});
}
Future<ArtistRef> getArtist(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
return ArtistRef.fromJson(r.data ?? const {});
}
Future<List<AlbumRef>> getArtistAlbums(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final raw = (r.data?['albums'] as List?) ?? const [];
return raw
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<List<TrackRef>> getArtistTracks(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id/tracks');
final raw = (r.data?['tracks'] as List?) ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<({AlbumRef album, List<TrackRef> tracks})> getAlbum(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/albums/$id');
final body = r.data ?? const <String, dynamic>{};
final tracks = ((body['tracks'] as List?) ?? const [])
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.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<Dio>((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<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
final homeProvider = FutureProvider<HomeData>((ref) async {
return (await ref.watch(libraryApiProvider.future)).getHome();
});
final artistProvider = FutureProvider.family<ArtistRef, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
});
final artistAlbumsProvider = FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
});
final artistTracksProvider = FutureProvider.family<List<TrackRef>, String>((ref, id) async {
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
});
final albumProvider =
FutureProvider.family<({AlbumRef album, List<TrackRef> 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<String, dynamic> _body;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? 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<String, dynamic> 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<Widget> children;
final double height;
final ScrollController? controller;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<AlbumRef> 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<ArtistRef> 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<TrackRef> 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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> like(LikeKind kind, String id) async {
await _dio.post<void>('/api/likes/${kind.path}/$id');
}
Future<void> unlike(LikeKind kind, String id) async {
await _dio.delete<void>('/api/likes/${kind.path}/$id');
}
/// Returns a payload of {artist_ids, album_ids, track_ids} the user has liked.
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})> ids() async {
final r = await _dio.get<Map<String, dynamic>>('/api/likes/ids');
final body = r.data ?? const <String, dynamic>{};
Set<String> 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<LikesApi>((ref) async {
return LikesApi(await ref.watch(dioProvider.future));
});
class LikedIds {
const LikedIds({required this.artists, required this.albums, required this.tracks});
final Set<String> artists;
final Set<String> albums;
final Set<String> 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<LikedIds> {
@override
Future<LikedIds> 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<void> 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<String> swap(Set<String> 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, LikedIds>(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<FabledSwordTheme>()!;
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<void> like(LikeKind kind, String id) async {
if (throwOnNext) throw StateError('boom');
}
@override
Future<void> unlike(LikeKind kind, String id) async {
if (throwOnNext) throw StateError('boom');
}
@override
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})> ids() async =>
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
}
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<void> setQueueFromTracks(List<TrackRef> 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<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration p) => _player.seek(p);
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> 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<MinstrelAudioHandler>((ref) {
throw UnimplementedError('overridden in main()');
});
final playbackStateProvider = StreamProvider<PlaybackState>(
(ref) => ref.watch(audioHandlerProvider).playbackState,
);
final mediaItemProvider = StreamProvider<MediaItem?>(
(ref) => ref.watch(audioHandlerProvider).mediaItem,
);
final queueProvider = StreamProvider<List<MediaItem>>(
(ref) => ref.watch(audioHandlerProvider).queue,
);
class PlayerActions {
PlayerActions(this._ref);
final Ref _ref;
Future<void> playTracks(List<TrackRef> 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<PlayerActions>((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<void> 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 `<manifest>`:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
```
Inside `<application>` (alongside the existing `<activity>`):
```xml
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
```
- [ ] **Step 17.5: Add iOS background mode**
Edit `flutter_client/ios/Runner/Info.plist`. Inside the top-level `<dict>`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
```
- [ ] **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<UnimplementedError>()));
});
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> 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<Dio>((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 122.
- §3 Architecture — Tasks 411, 17 cover scaffold, layering, auth flow, audio.
- §4 Theme tokens — Tasks 1, 5, 6.
- §5 Screens — Tasks 14 (home), 15 (artist + album), 1819 (player + nowplaying), 910 (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.