From d16ea16d5d195a04a3e12511def33625be0495a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 14:11:55 -0400 Subject: [PATCH] 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. --- ...-05-02-flutter-mobile-foundation-design.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md diff --git a/docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md b/docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md new file mode 100644 index 00000000..417c1e2b --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md @@ -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 `. 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 { + final Color accent, moss, bronze, oxblood; + final TextStyle display, body, mono; + // ... +} +``` + +Widgets read tokens via `Theme.of(context).extension()!.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` 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`. Notifies when token changes. +- `homeProvider` — `AsyncValue`. +- `artistProvider(id)` — family. +- `albumProvider(id)` — family. +- `likedProvider(EntityRef)` — family. Tracks per-entity like state across the app. +- `playerProvider` — `Stream` 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. " 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.