docs(m7): spec for Flutter mobile foundation + first slice

Lays the M7 #356 first-slice scope: project scaffold, theme system fed
from a shared FabledSword tokens.json, Riverpod + dio + just_audio
stack, auth flow against existing /api/auth/* (Bearer), library browse
(home/artist/album) with shell-route PlayerBar + NowPlaying, likes
included so layout settles. Tier 1 features beyond this slice (search,
discover, requests, settings, admin) get their own spec/plan cycles.
Desktop deferred to v1.1 as a Tauri-wrapped SvelteKit SPA, not Flutter
desktop.
This commit is contained in:
2026-05-02 14:11:55 -04:00
parent f77245bc41
commit d16ea16d5d
@@ -0,0 +1,313 @@
# M7 — Flutter mobile foundation + first vertical slice
> **Status:** Draft for review · 2026-05-02
>
> **Phase:** M7 task #356 — first slice. Lays the Flutter client foundation (project scaffold, theme system, auth, networking, audio backend) and ships a working app that authenticates, browses, and plays music. Subsequent slices (search, discover, requests, settings, admin, offline cache) get their own brainstorm/spec/plan cycles.
## 1. Goal
Land a Flutter mobile client (iOS + Android) that authenticates against an existing Minstrel server, browses the library, and plays music — including background playback with lock-screen controls. Visual identity matches the SvelteKit SPA via shared design tokens. Likes are surfaced wherever they live in browse so layout decisions settle early.
## 2. Goals and non-goals
### Goals
- Flutter project scaffold with iOS + Android targets enabled. Folder layout is desktop-ready (Linux/macOS/Windows targets stay disabled; structure does not preclude enabling them later).
- Server URL configuration on first launch — operator points the app at a Minstrel instance.
- Token-based login against `/api/auth/*`. Token stored in `flutter_secure_storage`. OIDC (#298) deferred.
- Theme system: FabledSword tokens exported from web as a single JSON source of truth; Flutter consumes via a generator that emits `tokens.dart` and a `FabledSwordTheme` `ThemeExtension`. Web side switches to consuming the same JSON via a CSS-vars generator. Fonts (Fraunces, Inter, JetBrains Mono) and SVG glyphs (album-fallback, etc.) sync via the same build pipeline.
- Library browse:
- **Home** mirrors the web home — Recently added albums, Rediscover, Most played tracks, Last played artists. Sections render as horizontal-scroll rows.
- **Artist detail** with header (artwork, name, Play button, Like button) and an albums grid.
- **Album detail** with header (artwork, title, artist, Play button, Like button) and a track list (each row has a Like button).
- Player:
- Mini PlayerBar in the shell route, persistent across navigation.
- NowPlaying full-screen view with cover, scrubber, transport controls, queue list.
- `just_audio` wrapped by `audio_service` for background audio + lock-screen / notification controls.
- `MediaItem` populated with track id, title, artist, album, artwork URL.
- Stream URL `/api/tracks/{id}/stream` with session token in request header.
- Likes ship in this slice. Heart-icon button, optimistic toggle with rollback on error. Surfaces: artist header, album header, track rows.
- Visual side-by-side audit: a screenshot pass per screen comparing web ↔ Flutter, gating slice-1 ship until they read as siblings.
- Server compatibility: client checks `/api/health` for a `min_client_version` field on launch and refuses to operate if too old. Server-side addition is part of this slice.
- Distribution:
- **Android:** debug APK from Forgejo CI on every push to `dev`; release APK on tag, attached to the Forgejo release.
- **iOS:** TestFlight build on tag (manual step v1).
- CI: Forgejo Actions workflow runs `flutter analyze`, `flutter test`, and `flutter build apk --debug` on every push.
### Non-goals (this slice)
- Search, discover, requests, admin, settings beyond server URL.
- Listening history, playlists.
- Offline cache (#357 — sibling task; separate brainstorm).
- iOS App Store / Google Play Store listings. APK + TestFlight only.
- OIDC login (#298 — separate task).
- Desktop targets (Linux/macOS/Windows). Tauri-wrapped SvelteKit ships post-v1 as a separate slice.
- Translations / i18n. English copy hard-coded in this slice.
- Per-track / per-album / per-playlist download UI (lives with #357).
## 3. Architecture
### 3.1 Project layout
```
flutter_client/
├── lib/
│ ├── main.dart # ProviderScope + audio_service init
│ ├── app.dart # MaterialApp.router + theme
│ ├── theme/
│ │ ├── tokens.dart # generated from shared/fabledsword.tokens.json
│ │ ├── theme_extension.dart # FabledSwordTheme (colors, type, spacing)
│ │ └── theme_data.dart # Material 3 ThemeData factory
│ ├── api/
│ │ ├── client.dart # dio instance + auth interceptor
│ │ ├── errors.dart # ApiError(code, message, status)
│ │ └── endpoints/ # one file per /api/* surface
│ │ ├── auth.dart
│ │ ├── library.dart
│ │ ├── likes.dart
│ │ └── player.dart # /api/queue, /api/now-playing
│ ├── models/ # DTOs mirroring web/src/lib/api/types.ts
│ ├── auth/
│ │ ├── server_url_screen.dart
│ │ ├── login_screen.dart
│ │ └── auth_provider.dart # Riverpod: session token + user
│ ├── library/
│ │ ├── home_screen.dart
│ │ ├── artist_detail_screen.dart
│ │ ├── album_detail_screen.dart
│ │ ├── library_providers.dart # AsyncValue providers
│ │ └── widgets/ # ArtistCard, AlbumCard, TrackRow, etc.
│ ├── player/
│ │ ├── audio_handler.dart # audio_service AudioHandler
│ │ ├── player_provider.dart # exposes state to UI
│ │ ├── player_bar.dart # mini player
│ │ └── now_playing_screen.dart
│ ├── likes/
│ │ ├── like_button.dart
│ │ └── likes_provider.dart
│ └── shared/
│ ├── widgets/ # buttons, pills, banners, error states
│ └── routing.dart # GoRouter config
├── shared/
│ └── fabledsword.tokens.json # source of truth; copied/symlinked from web
├── android/ ios/ # platform shells
├── tool/
│ └── gen_tokens.dart # tokens.json → tokens.dart
├── assets/
│ ├── fonts/ # Fraunces, Inter, JetBrains Mono (synced from web)
│ └── svg/ # album-fallback.svg etc. (synced from web)
└── test/ # widget + provider tests
```
### 3.2 Layering rules
- **api/** never depends on Flutter widgets — pure Dart, unit-testable without a tester.
- Providers sit between api/ and widgets. Widgets `ref.watch()` providers; providers call api/ and hold state. Widgets never call the api/ layer directly.
- **player/audio_handler.dart** is the only place that talks to `just_audio`. The rest of the app interacts via `player_provider`.
- `models/` are immutable DTOs with `fromJson` / `toJson`. Hand-written for slice 1; codegen (`json_serializable`) added later if the type surface grows past ~30 models.
### 3.3 Auth flow
1. App launches → reads `server_url` from `flutter_secure_storage`.
2. If missing → ServerUrl screen. Field validates by hitting `/api/health`.
3. If present → reads `session_token`. Missing or invalid → Login screen.
4. Login posts to `/api/auth/login`. On success, stores `session_token` and `current_user`.
5. dio interceptor reads the token from secure storage on every request, attaches `Authorization: Bearer <token>`. Server already supports both cookie (web SPA) and Bearer (non-browser clients) via `internal/auth/session.go`; Flutter uses Bearer to avoid cookie-jar handling.
6. Any 401 response → interceptor clears the token, navigates to Login.
### 3.4 Background audio
- `audio_service` runs the `AudioHandler` in a background isolate. UI talks to it via `playbackState` and `mediaItem` streams.
- `MediaItem` includes track id, title, artist, album, artwork URL.
- Lock-screen and notification controls read directly from `MediaItem`.
- Stream URL is the existing `/api/tracks/{id}/stream` with the session token in request header. `just_audio` supports custom HTTP headers via `LockCachingAudioSource` / `ProgressiveAudioSource`.
- Audio errors (network drop mid-stream, server 5xx on stream endpoint) → player pauses, surfaces a banner, queue position retained for resume.
## 4. Theme token sharing
### 4.1 Source of truth
`web/src/lib/styles/tokens.json` — a new file that defines every FabledSword token (colors, font sizes, line heights, spacing, radii) as plain JSON. The Flutter project consumes the same file (copied or symlinked into `flutter_client/shared/`).
### 4.2 Web consumption
Build step `scripts/tokens-to-css.js` reads `tokens.json` and emits `web/src/lib/styles/tokens.generated.css`:
```css
:root {
--fs-accent: #4A6B5C;
--fs-moss: ...;
...
}
```
Tailwind's `theme.extend` reads the same JSON via `tailwind.config.cjs`. Mechanical change to the web side: switch from hand-maintained CSS vars to generated ones. Existing class usage unchanged.
### 4.3 Flutter consumption
`flutter_client/tool/gen_tokens.dart` reads `tokens.json` and emits `lib/theme/tokens.dart`:
```dart
class FabledSwordTokens {
static const accent = Color(0xFF4A6B5C);
static const moss = Color(0xFF...);
static const fontDisplay = 'Fraunces';
static const fontBody = 'Inter';
// sizes, spacings, radii, etc.
}
```
`theme/theme_extension.dart`:
```dart
class FabledSwordTheme extends ThemeExtension<FabledSwordTheme> {
final Color accent, moss, bronze, oxblood;
final TextStyle display, body, mono;
// ...
}
```
Widgets read tokens via `Theme.of(context).extension<FabledSwordTheme>()!.accent`. Hard-coded colors are a lint failure — enforced via a custom `flutter_lints` rule or grep-based pre-commit check.
### 4.4 Fonts and assets
- Fonts (Fraunces, Inter, JetBrains Mono) live in `web/static/fonts/`. Copied to `flutter_client/assets/fonts/` by a sync script and registered in `pubspec.yaml`.
- SVG glyphs (`album-fallback.svg` and any others used in browse) copied to `flutter_client/assets/svg/`. Rendered with `flutter_svg`.
### 4.5 What this guarantees
Palette, typography, spacing parity. It does **not** guarantee identical widget behavior — a Material button has a Material ripple; a Tailwind button has whatever transitions you wrote. We accept that gap; both feel idiomatic to their platform while looking like the same product. Side-by-side mockup audit (web screenshot vs Flutter screenshot) per screen before slice ship.
## 5. First-slice screens
### 5.1 Server URL screen
- Single text field (URL, validated as reachable).
- "Connect" button hits `/api/health` and stores on 200.
- Error states: invalid URL, connection refused, certificate error.
- Re-shown if any later request fails with `connection_refused` or DNS error.
### 5.2 Login screen
- Username + password fields → `POST /api/auth/login`.
- Stores session token + user under `session_token` / `current_user`.
- Surfaces server errors via the same error-code mapping the web uses (`invalid_credentials`, `lidarr_unreachable`, etc.) — copy table lifted from `web/src/lib/api/error-copy.ts` exported as JSON, consumed by both clients.
- "Change server URL" link routes back to ServerUrl screen.
### 5.3 Home screen
- Mirrors the web home: 4 sections — Recently added albums, Rediscover (albums + artists), Most played tracks, Last played artists.
- One `homeProvider` returning `AsyncValue<HomeData>` from `GET /api/home`.
- Sections render as horizontal-scroll rows; section coupling matches the web (`HorizontalScrollRow.svelte` pattern — multi-row sections share a single horizontal scroller).
- Tap an artist/album card → routes to detail. Tap a track → starts playback at that track, queue = remainder of the section's track list.
- Pull-to-refresh re-fetches the home payload.
### 5.4 Artist detail
- Header: artwork (square or circular per FabledSword), name, large accent-fill Play button (48dp diameter), LikeButton.
- Albums grid below (cards link to album detail).
- Backed by `GET /api/artists/{id}` + `GET /api/artists/{id}/albums`.
- Play button → fetches all artist tracks, shuffles, plays.
- Like button → toggles `POST/DELETE /api/likes/artists/{id}` with optimistic update.
### 5.5 Album detail
- Header: artwork, title, artist, Play button, LikeButton.
- Track list with index, title, duration, per-row LikeButton (track-level).
- Backed by `GET /api/albums/{id}` + `GET /api/albums/{id}/tracks`.
- Tap a track → queue = whole album from that track forward, starts playback.
- Play button → starts at track 1, queue = whole album.
### 5.6 PlayerBar (shell-route persistent)
- Visible on every screen except ServerUrl, Login.
- Cover (small), title/artist (truncated), play/pause, skip-next.
- Tap → expands NowPlaying.
- Hidden when `audio_service` reports no active queue.
### 5.7 NowPlaying screen
- Full-screen. Large cover, title/artist, scrubber, play/pause/skip-prev/skip-next.
- Queue list below; each row tappable to jump.
- Swipe down to dismiss.
### 5.8 Routing
`GoRouter` with shell route. PlayerBar lives in the shell so it persists across navigation. Tab placeholders (home/search/requests/settings) are present in the bottom nav; only home is wired in this slice. Search/requests/settings tabs render a "Coming in slice N" placeholder so the nav layout settles.
### 5.9 State boundaries
Riverpod providers, all `AsyncValue`-typed unless noted:
- `serverUrlProvider``String?` from secure storage.
- `authProvider``AsyncValue<User?>`. Notifies when token changes.
- `homeProvider``AsyncValue<HomeData>`.
- `artistProvider(id)` — family.
- `albumProvider(id)` — family.
- `likedProvider(EntityRef)` — family. Tracks per-entity like state across the app.
- `playerProvider``Stream<PlayerState>` exposed as a Riverpod stream provider.
## 6. Backend touch-points
Slice-1 server-side work is small:
- **`min_client_version` in `/api/health`** — 5-line addition. Returns `{"status": "ok", "min_client_version": "0.1.0"}`. Client compares against its build version and refuses if too old.
- **Bearer-auth login response** — confirm `/api/auth/login` returns the session token in the response body (not just as a `Set-Cookie` header) so the Flutter client can store it in secure storage. If the current handler only sets a cookie, add the token to the JSON response. Trivial change, surfaced in the scaffold task.
- Confirm `/api/tracks/{id}/stream` honors `Range` headers fully. Required for `just_audio` seek; expected to already work, but verified in the scaffold task.
No new endpoints are required for slice 1. Delta-sync, sync tokens, stream-quality negotiation, and any endpoint additions for offline (#357) are deferred to those slices' brainstorms.
## 7. Error handling
dio interceptor maps server responses into typed `ApiError(code, message, status)` regardless of envelope shape (`{error: "code"}` vs `{error: {code, message}}`). UI consumes `AsyncValue.error` and surfaces:
- `connection_refused` / DNS errors → reusable `ConnectionErrorBanner` with Retry, plus a "Change server URL" link back to the ServerUrl screen.
- `unauthenticated` → token cleared, push Login.
- Any other code → toast with the user-facing message from the shared error-copy JSON consumed by both clients.
- Audio errors (network drop mid-stream, 5xx on stream endpoint) → player pauses, banner shown, queue position retained for resume.
- `version_too_old` (from `min_client_version` check on launch) → blocking modal: "Update required to use this server. <Update button>" linking to the latest APK / TestFlight.
## 8. Testing
Three layers:
- **Unit tests** for `api/` and providers: fake dio with mocked responses, assert provider state transitions. No widgets involved. Fast, runs in `flutter test` without a tester.
- **Widget tests** for screens: pump screen with `ProviderScope` overrides for the providers it depends on, assert text/buttons render correctly. Same idea as the Vitest + Testing-Library pattern on the web side.
- **Integration test** for the auth + browse + play happy path: real server stub on localhost, real `audio_service`. One golden-path test for slice 1.
Forgejo Actions `flutter.yml` runs `flutter analyze` + `flutter test` + `flutter build apk --debug` on every push to `dev`. iOS build gated to tagged releases.
## 9. Distribution and versioning
- **Android:** debug APK on every push to `dev` (CI artifact). Release APK on tag, attached to the Forgejo release page. Self-host install via the release page; no Play Store for v1.
- **iOS:** TestFlight build on tag (manual step v1, automated later). No App Store for v1.
- **Versioning:** Flutter app version mirrors the server tag (`v0.1.0` server → `0.1.0` Flutter). Within a tagged release, client and server are paired; no feature drift.
- Server-side `min_client_version` enforces the pairing — old clients can't talk to new servers (or vice versa) and are told to update.
## 10. Open decisions tracked for later slices
These are explicitly *not* decided here; they belong to subsequent brainstorms but are listed so we don't lose them:
- **Codegen for models** — hand-written DTOs in slice 1; revisit when type surface exceeds ~30 models.
- **Push notifications** — out of scope for v1 mobile; revisit alongside #366 (notifications).
- **Crash reporting** — out of scope for slice 1; pick a self-hosted target (Sentry, Glitchtip) before v1 ship.
- **OIDC client integration** (#298) — `flutter_appauth` is the standard Flutter library. Slot in once server-side OIDC lands.
- **Offline cache** (#357) — sibling task. The Riverpod provider boundaries are designed so a `dataSource` provider can swap remote ↔ local without rewiring consumers.
- **Desktop Tauri shell** — post-v1 wrap of the SvelteKit SPA. Not part of this spec.
## 11. Origin
Operator request 2026-05-02: "create an m7 and push all of these ideas into it. I want v1 to ship as a full product. I want a full flutter app with all the functionality that would require." Scope decisions made in brainstorm 2026-05-02 (this doc):
- Mobile-first for v1; desktop deferred to v1.1 as a Tauri-wrapped SPA (not Flutter desktop).
- This brainstorm covers foundation + first vertical slice (auth + browse + player + likes); subsequent features are their own brainstorm/spec/plan cycles.
- Stack: Riverpod + dio + just_audio + audio_service + flutter_secure_storage + Material 3 + ThemeExtension from FabledSword tokens.
- Likes included in slice 1 (operator: "include in slice 1 as they will affect layout and I don't want to have a lot of refactoring later").
## 12. Related
- M7 #356 — this spec implements its first slice.
- M7 #357 — offline cache; sibling, separate brainstorm. Architecture here designed to slot it in cleanly.
- M7 #298 — OIDC; deferred. Slice 1 is token-only.
- M7 #351 — Profile/Account; surfaces in a later Settings slice.
- M7 #352 — Playlists; out of scope for slice 1.