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:
@@ -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.
|
||||
Reference in New Issue
Block a user