Files
minstrel/docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md
T
bvandeusen d16ea16d5d 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.
2026-05-02 14:11:55 -04:00

18 KiB

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:

: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:

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:

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:

  • serverUrlProviderString? from secure storage.
  • authProviderAsyncValue<User?>. Notifies when token changes.
  • homeProviderAsyncValue<HomeData>.
  • artistProvider(id) — family.
  • albumProvider(id) — family.
  • likedProvider(EntityRef) — family. Tracks per-entity like state across the app.
  • playerProviderStream<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. " 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").
  • 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.