Imported from trashpin/radio (
AGENTS.md). Install upstream withnpx skills add trashpin/radio. Copyright stays with the author.
ExplorerOS-Mobile
Read-only Flutter mobile client for ExplorerOS destinations (National Park Buddy, Florida Buddy, Historic Route 66, and future destinations). Destination content is fetched at runtime from the backend (Supabase) — never hardcode destinations.
Project layout (lib/)
main.dart— entry point: initializes Supabase, wraps app in RiverpodProviderScope.app/app.dart— rootMaterialApp.router(theme + navigation).core/constants/— app-wide constants and backend env-var names.core/theme/—app_colors.dart,app_typography.dart,app_theme.dart.core/error/—AppExceptionmodel +ErrorHandler(maps backend errors).core/router/—go_routerconfig;app_scaffold.dartholds the bottom nav.services/—supabase_service.dart(single doorway to the backend).models/— read-only data models (e.g.Destination).shared/widgets/— reusable widgets (LoadingWidget,ErrorView,ComingSoonView).features/<feature>/— feature-first screens (home, destinations, map, radio, profile, settings).
Standard commands
Run from repo root (see Flutter docs for details):
- Install deps:
flutter pub get - Lint:
flutter analyze - Test:
flutter test - Run (web):
flutter run -d chrome(or a headless server, see below)
Cursor Cloud specific instructions
- Flutter SDK is installed at
~/flutterand added toPATHvia~/.bashrc. Ifflutteris not found in a non-interactive shell, invoke it as~/flutter/bin/flutteror runexport PATH="$HOME/flutter/bin:$PATH"first. - Only the web toolchain is available here. The Android SDK and the
Linux desktop toolchain (ninja/GTK) are NOT installed, so
flutter runtargeting Android/Linux will fail. Use web for verification. - Demoing the app in this VM (no auto-launching browser device): serve it
headlessly and open it with the browser/computer-use tool:
flutter run -d web-server --web-port=8080 --web-hostname=0.0.0.0, then browse tohttp://localhost:8080. The first web compile takes ~15–20s before the app appears — wait for it rather than assuming a blank screen is a failure. - Backend config is not committed. Supabase URL/key are read at runtime from
a gitignored
.envfile (loaded byflutter_dotenv). Copy.env.exampleto.envand fill inSUPABASE_URL/SUPABASE_ANON_KEYfrom the Supabase dashboard. The startup update script generates.envautomatically: if theSUPABASE_URL/SUPABASE_ANON_KEYenvironment variables are set (e.g. added as Cursor secrets) it writes them into.env; otherwise it copies.env.example. So builds never fail on the missing asset — but with blank values the app boots, Settings shows "Not configured", and the Destinations tab shows a friendly "cannot reach" message. This is expected until real keys are added. To connect for real in a cloud run, addSUPABASE_URLandSUPABASE_ANON_KEYas secrets. .envis declared as a Flutter asset inpubspec.yaml; it must exist forflutter run/buildto succeed (hence the auto-copy above).- Database schema + demo seed live in
supabase/(migrations/0001_exploreros_schema.sql,seed.sql). Apply them in the Supabase SQL editor / CLI so the app has live content. Table columns are the snake_case contract for the Dart models'fromJson/toJson— keep them in sync when changing a model. - Client key MUST be the publishable key. Supabase rejects
sb_secret_…keys from browser clients with HTTP 401"Forbidden use of secret API key in browser"(server-sidecurlstill works, which is misleading). Flutter web is a browser client, soSUPABASE_ANON_KEYmust be thesb_publishable_…(anon) key from Project Settings → API. A secret key will 401 every query even though the URL/schema are correct. - The live project uses Base44's schema, not the app's original tables. The
connected Supabase project is managed by Base44 Admin: content lives in
destinations,media(audio/photo/video; audiofile_urlresolves against the publicmp3Storage bucket),stops, andstories. The app's oldersongs/radio_stations/music_*tables do NOT exist there. Mapping is handled inDestination.fromJson(Base44 columns:destination_id,hero_image,destination_type,state_province/country) and infeatures/media/(MediaRepository.songsForDestinationmaps audiomediarows toSongs;RadioStation.fromDestinationderives a station per destination). Radio playlists come from a destination's audiomedia, not aradio_stationstable. - Google Maps (Map tab). The web build loads the Maps JavaScript API via a
<script>inweb/index.htmlwith the placeholder__GOOGLE_MAPS_API_KEY__(the raw key is never committed). Inject the real key into the builtbuild/web/index.html(e.g.sed -i "s/__GOOGLE_MAPS_API_KEY__/$GOOGLE_MAPS_API_KEY/" build/web/index.html) afterflutter build web, before serving. It's a client-side key by design — restrict it by HTTP referrer and enable "Maps JavaScript API" in Google Cloud Console. The Map plots destinations with coordinates (latitude/longitude). - Gotcha: after adding a web plugin,
flutter cleanbefore building. Flutter can reuse a cachedweb_plugin_registrant.dartthat omits a newly added web plugin. Symptom:google_maps_flutterthrows"TargetPlatform.linux is not yet supported by the maps plugin"on web even though the key/schema are fine. Fix:flutter clean && flutter pub get && flutter build webso the registrant regenerates withGoogleMapsPlugin.registerWith. - Map clustering needs the markerclusterer script.
google_maps_flutter_web'sClusterManagerbinds to the globalmarkerClusterer, loaded by the@googlemaps/markerclusterer<script>inweb/index.html. If that script is removed, the whole web map fails to initialize (Cannot read properties of undefined (reading 'MarkerClusterer')) whenever aClusterManageris present. - Server-side AI tools read keys from the environment, not
.env. The content pipeline scripts intool/(research_destination.dart,story_generate.dart,story_audio.dart,dj_audio.dart,narration_audio.dart) readOPENAI_API_KEY/ELEVENLABS_API_KEYandSUPABASE_SERVICE_KEY(falls back toSUPABASE_ANON_KEY) from environment variables (Cursor secrets), so they need those secrets set — the Flutter app's.envis not used by them. Run e.g.dart run tool/research_destination.dart --destination "Rainbow Springs State Park" --category state_park --state Florida(add--dry-runto validate wiring without calling OpenAI). - Central audio engine:
lib/features/playback/.PlaybackManager(playback_manager.dart) is the single source of truth for all music + narration playback; other systems (GPS, Explorer Radio, narration, POIs) should route audio through it, never touchjust_audiodirectly. It runs twoAudioOutputchannels (music + narration) so music is ducked/faded and resumed around narration, and it uses a priority queue (emergency 100 → music 10). State transitions are optimistic: state/events update synchronously and the actual source load runs in the background (load failures surface via theerrorOccurredevent), so never rely onplay()resolving before the state flips. Riverpod entry points are inplayback_providers.dart; unit tests use a fakeAudioOutput(test/playback_manager_test.dart). The admin Playback Debug module (Platform group) exercises the engine end-to-end in a browser. - Narration "Generate" buttons only enqueue
generation_jobs— they do not create scripts/audio directly (the AI keys are server-side only). A worker must drain the queue: the.github/workflows/narration-worker.ymlGitHub Action (runs every 15 min + on demand viarun.ts), or thetool/*.dart/tool/*.pyCLIs run manually. If admins report "the queue isn't running automatically," check Actions → Narration Worker run logs. - The queue worker depends on THREE GitHub Actions repo secrets. The workflow
aborts in seconds (and nothing drains) unless these exist under GitHub →
Settings → Secrets and variables → Actions:
SUPABASE_SERVICE_KEY,OPENAI_API_KEY,ELEVENLABS_API_KEY(optionalSUPABASE_URL, else defaults to the project URL). These are separate from the app.envand from Cursor secrets — setting Cursor secrets does NOT populate GitHub Actions secrets. The worker (supabase/functions/narration-worker/worker.ts) drains:research,narration,narration_audio,full, pluswikimedia_import(Commons hero images →mediabucket +locations.images+media_assets) andaudiojobs whosenotesstart withmaster_location(OpenAI+ElevenLabs narration →voiceoversbucket +locations.audio_files). Otheraudio:*variants (species records,dj_banter, batch) are intentionally left for their own tooling. It self-healsrunningrows back topendingat the start of each run (safe because the workflow's concurrency group serializes runs).