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.
128 KiB
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. 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—handleHealthzreturnsmin_client_versioninternal/server/server_test.go— extend healthz testinternal/server/version.go(create) —MinClientVersionconstant
Web — create (token + error-copy source-of-truth refactor)
web/src/lib/styles/tokens.json— JSON source of truth for FabledSword tokensweb/scripts/tokens-to-css.js— generatestokens.generated.cssfromtokens.jsonweb/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 bytokens.generated.css; old file deletedweb/src/app.css— importtokens.generated.cssinstead offabledsword-tokens.cssweb/src/lib/api/error-copy.ts— read fromerror-copy.jsoninstead of inline literalweb/package.json— addtokensscript invokingnode scripts/tokens-to-css.jsweb/vite.config.ts— runtokensscript 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— addsrc/lib/styles/tokens.generated.css -
Modify:
web/package.json— addtokensscript -
Modify:
web/vite.config.ts— pre-build hook -
Modify:
web/src/app.css— importtokens.generated.css -
Delete:
web/src/lib/styles/fabledsword-tokens.css -
Step 1.1: Create
web/src/lib/styles/tokens.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
#!/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
tokensscript + Vite plugin hook
In web/package.json under "scripts":
"tokens": "node scripts/tokens-to-css.js",
In web/vite.config.ts, add a Vite plugin that runs the script before build/dev:
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.cssto import generated CSS
Replace @import './lib/styles/fabledsword-tokens.css'; with:
@import './lib/styles/tokens.generated.css';
- Step 1.5: Add
.gitignoreentry
Append to web/.gitignore:
src/lib/styles/tokens.generated.css
- Step 1.6: Delete
fabledsword-tokens.css
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
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:
{
"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.tsto read JSON
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
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:
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
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
handleHealthzininternal/server/server.go
Replace the existing handler (around line 103):
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
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/(viaflutter create) -
Step 4.1: Run
flutter create
Run from repo root:
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:
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
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
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.dartwith stub
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:
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
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
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
#!/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/"
chmod +x flutter_client/tool/sync_shared.sh
- Step 5.2: Run sync to populate
shared/andassets/
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
// 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
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
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:
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
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
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:
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
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:
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
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
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:
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
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
cd flutter_client && flutter test test/api/
Expected: PASS.
- Step 7.8: Commit
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
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
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
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
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
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:
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
cd flutter_client && flutter test test/models/
Expected: PASS.
- Step 8.9: Commit
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
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:
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
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
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
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
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
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:
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
cd flutter_client && flutter test test/auth/
Expected: PASS.
- Step 10.5: Commit
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
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
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:
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:
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
cd flutter_client && flutter test
Expected: PASS (auth/login_screen_test still passes; smoke_test now lands on server-url).
- Step 11.6: Commit
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
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
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:
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
cd flutter_client && flutter test test/api/
Expected: PASS.
- Step 12.6: Commit
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
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
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
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
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:
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
cd flutter_client && flutter test test/library/
Expected: PASS.
- Step 13.7: Commit
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
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:
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:
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
cd flutter_client && flutter test test/library/home_screen_test.dart
Expected: PASS.
- Step 14.5: Commit
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
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
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:
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:
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:
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
cd flutter_client && flutter test test/library/
Expected: PASS.
- Step 15.6: Commit
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
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
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
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:
// 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:
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:
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
cd flutter_client && flutter test
Expected: PASS.
- Step 16.8: Commit
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
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
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:
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>:
<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>):
<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>:
<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:
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
cd flutter_client && flutter test test/player/
Expected: PASS.
- Step 17.8: Commit
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_ShellWithPlayerBarbody 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
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:
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:
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:onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks), - Per-track tap: replace
onTap: () { /* play wired in Task 18 */ }with: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:
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
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
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-playingroute -
Step 19.1: Create
lib/player/now_playing_screen.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-playingroute
In lib/shared/routing.dart, inside the ShellRoute's routes: list, append:
import '../player/now_playing_screen.dart';
// ...
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
- Step 19.3: Verify build
cd flutter_client && flutter analyze && flutter test
Expected: clean.
- Step 19.4: Commit
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 onconnection_refused -
Step 20.1: Create the banner widget
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:
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
on401in the dio provider
Modify lib/library/library_providers.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:
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
cd flutter_client && flutter test
Expected: PASS.
- Step 20.6: Commit
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
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
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
# 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
-
Spec coverage check:
- §2 Goals — all in tasks 1–22.
- §3 Architecture — Tasks 4–11, 17 cover scaffold, layering, auth flow, audio.
- §4 Theme tokens — Tasks 1, 5, 6.
- §5 Screens — Tasks 14 (home), 15 (artist + album), 18–19 (player + nowplaying), 9–10 (auth).
- §6 Backend touch-points — Task 3 (
/healthzmin_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.processingStateand 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.
-
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. -
Type consistency:
LikeKind,LikedIds,PlayerActions,MinstrelAudioHandler,FabledSwordTheme,ApiError,TokenResolver,OnUnauthenticatedall consistent across tasks. -
One spec deviation noted: spec §6 mentions a possible "Bearer-auth login response" backend touch-point. Inspection in this plan-writing pass confirmed
LoginResponsealready 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.