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.
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 influtter_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.dartand aFabledSwordThemeThemeExtension. 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_audiowrapped byaudio_servicefor background audio + lock-screen / notification controls.MediaItempopulated with track id, title, artist, album, artwork URL.- Stream URL
/api/tracks/{id}/streamwith 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/healthfor amin_client_versionfield 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).
- Android: debug APK from Forgejo CI on every push to
- CI: Forgejo Actions workflow runs
flutter analyze,flutter test, andflutter build apk --debugon 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 viaplayer_provider. models/are immutable DTOs withfromJson/toJson. Hand-written for slice 1; codegen (json_serializable) added later if the type surface grows past ~30 models.
3.3 Auth flow
- App launches → reads
server_urlfromflutter_secure_storage. - If missing → ServerUrl screen. Field validates by hitting
/api/health. - If present → reads
session_token. Missing or invalid → Login screen. - Login posts to
/api/auth/login. On success, storessession_tokenandcurrent_user. - 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) viainternal/auth/session.go; Flutter uses Bearer to avoid cookie-jar handling. - Any 401 response → interceptor clears the token, navigates to Login.
3.4 Background audio
audio_serviceruns theAudioHandlerin a background isolate. UI talks to it viaplaybackStateandmediaItemstreams.MediaItemincludes track id, title, artist, album, artwork URL.- Lock-screen and notification controls read directly from
MediaItem. - Stream URL is the existing
/api/tracks/{id}/streamwith the session token in request header.just_audiosupports custom HTTP headers viaLockCachingAudioSource/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 toflutter_client/assets/fonts/by a sync script and registered inpubspec.yaml. - SVG glyphs (
album-fallback.svgand any others used in browse) copied toflutter_client/assets/svg/. Rendered withflutter_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/healthand stores on 200. - Error states: invalid URL, connection refused, certificate error.
- Re-shown if any later request fails with
connection_refusedor 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 fromweb/src/lib/api/error-copy.tsexported 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
homeProviderreturningAsyncValue<HomeData>fromGET /api/home. - Sections render as horizontal-scroll rows; section coupling matches the web (
HorizontalScrollRow.sveltepattern — 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_servicereports 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_versionin/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/loginreturns the session token in the response body (not just as aSet-Cookieheader) 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}/streamhonorsRangeheaders fully. Required forjust_audioseek; 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 → reusableConnectionErrorBannerwith 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(frommin_client_versioncheck 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 influtter testwithout a tester. - Widget tests for screens: pump screen with
ProviderScopeoverrides 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.0server →0.1.0Flutter). Within a tagged release, client and server are paired; no feature drift. - Server-side
min_client_versionenforces 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_appauthis the standard Flutter library. Slot in once server-side OIDC lands. - Offline cache (#357) — sibling task. The Riverpod provider boundaries are designed so a
dataSourceprovider 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.