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