Author SHA1 Message Date
Hermes Agent d3ae2b6dc4 docs: add PROJECT_OVERVIEW.md with setup instructions, tech stack, and known issues 2026-08-01 00:17:18 +00:00
robot f3393348f7 docs: add project structure and architecture analysis report
- Map out complete folder structure with descriptions
- Identify Flutter+Dart as primary stack (no Go backend)
- Document all Dart source modules and their responsibilities
- Document native platform code (Android Cast, iOS CarPlay)
- Describe whitelabel build system (build_whitelabel.py + theme.json)
- Document data flow between modules
- List dependencies, dev environment, and test coverage
2026-07-31 23:59:33 +00:00
18 changed files with 471 additions and 814 deletions
-223
View File
@@ -1,223 +0,0 @@
# kryz-go-flutter CI Pipeline
# Triggers on push and pull_request
# Stages: lint -> test -> build (Android + iOS) -> artifacts
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Cancel in-progress runs on new push to same branch
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
env:
FLUTTER_VERSION: "3.41.x"
jobs:
# ── Stage 1: Lint & static analysis ──────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
if: hashFiles('theme.json') == ''
run: python3 ci_stub_gen.py
- name: Analyze Dart code
run: flutter analyze --fatal-warnings
- name: Check formatting
run: dart format --set-exit-if-changed lib/ test/
# ── Stage 2: Unit & widget tests ─────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
run: python3 ci_stub_gen.py
- name: Run tests
run: flutter test --reporter=expanded
- name: Run tests with coverage
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: flutter test --coverage
- name: Upload coverage report
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/lcov.info
retention-days: 14
# ── Stage 3: Android build ──────────────────────────────────────
build-android:
name: Build Android
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Pre-cache Android platform artifacts
run: flutter precache --android
- name: Accept Android SDK licenses
run: |
yes | sdkmanager --licenses || true
- name: Setup Python (for whitelabel build script)
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
run: python3 ci_stub_gen.py
- name: Analyze (gate before build)
run: flutter analyze --fatal-warnings
- name: Run tests (gate before build)
run: flutter test --reporter=expanded
# Optional: run whitelabel build script to regenerate lib/generated/
# Skip gracefully if theme.json is missing or script fails
- name: Run whitelabel build script
if: hashFiles('theme.json') != ''
run: python3 build_whitelabel.py
- name: Configure release signing
if: env.ANDROID_KEYSTORE_BASE64 != ''
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASS: ${{ secrets.ANDROID_KEY_PASS }}
run: |
# Decode keystore
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
# Write key.properties for Gradle to consume
cat > android/key.properties <<EOF
storePassword=$ANDROID_KEYSTORE_PASS
keyPassword=$ANDROID_KEY_PASS
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../release.keystore
EOF
- name: Build APK (release)
run: flutter build apk --release
- name: Build App Bundle (release)
run: flutter build appbundle --release
- name: List build outputs
run: |
echo "=== APK output ==="
ls -lh build/app/outputs/flutter-apk/ || true
echo "=== AAB output ==="
ls -lh build/app/outputs/bundle/release/ || true
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: android-apk
path: build/app/outputs/flutter-apk/app-release.apk
retention-days: 90
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: android-aab
path: build/app/outputs/bundle/release/app-release.aab
retention-days: 90
# ── Stage 3 (parallel): iOS build ───────────────────────────────
# Note: requires a macOS runner. Uses 'self-hosted' label which
# matches our Gitea Actions runner. Falls back to --no-codesign
# if Apple signing secrets are not configured.
build-ios:
name: Build iOS
runs-on: self-hosted
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Install CocoaPods dependencies
run: cd ios && pod install --repo-update
- name: Build iOS (release, no-codesign)
run: flutter build ios --release --no-codesign
- name: Upload iOS build artifact
uses: actions/upload-artifact@v4
with:
name: ios-app
path: build/ios/iphoneos/
retention-days: 90
-277
View File
@@ -1,277 +0,0 @@
# kryz-go-flutter Release Pipeline
# Triggers on version tags (v1.0.0, v1.0.1, etc.)
# Produces signed artifacts and attaches them to a Gitea Release
name: Release
on:
push:
tags:
- "v*"
env:
FLUTTER_VERSION: "3.41.x"
jobs:
# ── Lint (gate) ─────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
if: hashFiles('theme.json') == ''
run: python3 ci_stub_gen.py
- name: Analyze Dart code
run: flutter analyze --fatal-warnings
- name: Check formatting
run: dart format --set-exit-if-changed lib/ test/
# ── Test (gate) ──────────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
run: python3 ci_stub_gen.py
- name: Run tests
run: flutter test --reporter=expanded
# ── Android release build ───────────────────────────────────
build-android:
name: Build Android Release
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Pre-cache Android platform artifacts
run: flutter precache --android
- name: Accept Android SDK licenses
run: |
yes | sdkmanager --licenses || true
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Flutter dependencies
run: flutter pub get
- name: Generate stubs (lint gate support)
run: python3 ci_stub_gen.py
# If release signing secrets are available, write keystore
# and update build.gradle.kts to use a release signing config.
# Otherwise fall back to debug keys (current state of the repo).
- name: Configure release signing
if: env.ANDROID_KEYSTORE_BASE64 != ''
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASS: ${{ secrets.ANDROID_KEY_PASS }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
cat > android/key.properties <<EOF
storePassword=$ANDROID_KEYSTORE_PASS
keyPassword=$ANDROID_KEY_PASS
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../release.keystore
EOF
- name: Build APK (release)
run: flutter build apk --release
- name: Build App Bundle (release)
run: flutter build appbundle --release
- name: List build outputs
run: |
echo "=== APK output ==="
ls -lh build/app/outputs/flutter-apk/ || true
echo "=== AAB output ==="
ls -lh build/app/outputs/bundle/release/ || true
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: release-android-apk
path: build/app/outputs/flutter-apk/app-release.apk
retention-days: 90
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: release-android-aab
path: build/app/outputs/bundle/release/app-release.aab
retention-days: 90
# ── iOS release build ───────────────────────────────────────
# Note: requires a macOS runner. Uses 'self-hosted' label which
# matches our Gitea Actions runner. Falls back to --no-codesign
# if Apple signing secrets are not configured.
build-ios:
name: Build iOS Release
runs-on: self-hosted
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Install CocoaPods dependencies
run: cd ios && pod install --repo-update
# Configure Apple signing if secrets are available
- name: Install Apple certificate
if: env.APPLE_CERTIFICATE_P12 != ''
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASS: ${{ secrets.APPLE_CERTIFICATE_PASS }}
APPLE_PROVISION_PROFILE: ${{ secrets.APPLE_PROVISION_PROFILE }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
PROVISION_PATH=$RUNNER_TEMP/profile.mobileprovision
echo "$APPLE_CERTIFICATE_P12" | base64 -d > "$CERTIFICATE_PATH"
echo "$APPLE_PROVISION_PROFILE" | base64 -d > "$PROVISION_PATH"
security create-keychain -p "" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "" "$KEYCHAIN_PATH"
security set-keychain-settings -t 3600 -l "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASS" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "" "$KEYCHAIN_PATH"
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PROVISION_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
- name: Build iOS (release)
if: env.APPLE_CERTIFICATE_P12 != ''
run: flutter build ios --release
- name: Build iOS (release, no-codesign)
if: env.APPLE_CERTIFICATE_P12 == ''
run: flutter build ios --release --no-codesign
- name: Upload iOS artifact
uses: actions/upload-artifact@v4
with:
name: release-ios-app
path: build/ios/iphoneos/
retention-days: 90
# ── Publish Gitea Release ────────────────────────────────────
publish-release:
name: Publish Release
runs-on: ubuntu-latest
needs: [build-android, build-ios]
steps:
- name: Download Android APK
uses: actions/download-artifact@v4
with:
name: release-android-apk
- name: Download Android AAB
uses: actions/download-artifact@v4
with:
name: release-android-aab
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ gitea.ref_name }}
GITEA_BASE: https://git.westerntechnologies.duckdns.org
run: |
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release build for $TAG\"}" \
"$GITEA_BASE/api/v1/repos/kfj001/kryz-go-flutter/releases"
- name: Upload APK to Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ gitea.ref_name }}
GITEA_BASE: https://git.westerntechnologies.duckdns.org
run: |
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @app-release.apk \
"$GITEA_BASE/api/v1/repos/kfj001/kryz-go-flutter/releases/assets?name=app-release.apk&tag_name=$TAG"
- name: Upload AAB to Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ gitea.ref_name }}
GITEA_BASE: https://git.westerntechnologies.duckdns.org
run: |
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @app-release.aab \
"$GITEA_BASE/api/v1/repos/kfj001/kryz-go-flutter/releases/assets?name=app-release.aab&tag_name=$TAG"
+211
View File
@@ -0,0 +1,211 @@
# KRYZ Go! — Project Overview
> Mobile streaming client for KRYZ LP-FM 98.5 Mariposa community radio station.
## Tech Stack
| Layer | Technology |
|---|---|
| **Framework** | Flutter (Dart SDK ^3.11.5) |
| **Audio playback** | `just_audio` + `just_audio_background` + `audio_service` |
| **Network** | `http` (HTTP client for live-metadata API) |
| **Date/time** | `intl` + `timezone` |
| **Casting (Android)** | Google Cast framework (`play-services-cast-framework:22.1.0`) via Kotlin platform channel |
| **Casting (iOS)** | AirPlay via Swift platform channel |
| **Android Auto** | `MediaBrowserService` (Kotlin) + `androidx.media:1.7.0` |
| **iOS CarPlay** | Custom `CarPlaySceneDelegate` (Swift) |
| **Whitelabel build** | Python 3 + `Pillow` + `cairosvg` (`build_whitelabel.py`) |
| **Development** | VS Code Dev Container (Docker), `flutter_lints` |
| **Testing** | `flutter_test` (widget + unit tests) |
**Target platforms:** Android, iOS, macOS, Windows, Linux, Web.
### Key Dependencies
- `just_audio` (^0.10.5) — core audio playback engine
- `audio_service` (^0.18.18) — media session integration (lock screen, notifications)
- `audio_session` (^0.2.2) — audio focus / duck configuration
- `just_audio_background` (^0.0.1-beta.17) — background audio on mobile
- `http` (^1.5.0) — HTTP client for live-info metadata polling
- `timezone` (^0.10.1) — timezone-aware datetime handling
- `intl` (^0.20.2) — date/time formatting utilities
- `webview_flutter` (^4.13.0) — embedded WebView (declared but unused in current code path)
- `cupertino_icons` (^1.0.8) — iOS-style icon font
### Native Dependencies (Android)
- `play-services-cast-framework:22.1.0`
- `androidx.mediarouter:1.7.0`
- `androidx.media:1.7.0`
- Java/Kotlin target: Java 17
## Directory Structure
```
kryz-go-flutter/
├── lib/ # Dart application source
│ ├── main.dart # Entry point + MaterialApp + MainPage UI
│ ├── kryz_audio_handler.dart # Background audio handler (audio_service)
│ ├── streaming_audio_business_object.dart # Audio playback controller
│ ├── casting_business_object.dart # Cast/AirPlay state machine (platform channels)
│ ├── live_info_business_object.dart # Live metadata poller + data models
│ ├── live_info_panel.dart # "Live Studio Feed" widget (now/next cards)
│ ├── track_card.dart # Individual track display widget
│ ├── show_card.dart # Show schedule display widget
│ ├── recently_played_list.dart # Recently-played list widget (unused in current UI)
│ ├── app_theme_business_object.dart # Theme colors + light/dark ThemeData
│ ├── time_formatter.dart # Timestamp parsing + timezone conversion
│ └── generated/ # Auto-generated by build_whitelabel.py (not committed)
│ ├── app_config.dart # Compile-time constants
│ └── theme_colors.dart # Generated AppThemeColors
├── android/ # Android native project
│ └── app/src/main/
│ ├── kotlin/.../MainActivity.kt # Cast framework + MethodChannel bridge
│ ├── kotlin/.../KryzAutoMediaBrowserService.kt # Android Auto media browser
│ ├── kotlin/.../CastOptionsProvider.kt # Cast receiver config
│ ├── AndroidManifest.xml # App manifest + permissions
│ └── res/values/automotive_colors.xml # Android Auto theming
├── ios/ # iOS native project
│ └── Runner/
│ ├── AppDelegate.swift # Flutter engine + CarPlay setup
│ ├── SceneDelegate.swift # Scene lifecycle
│ └── CarPlaySceneDelegate.swift # CarPlay template
├── macos/ # macOS native project (Swift)
├── linux/ # Linux native project (C++)
├── windows/ # Windows native project (C++)
├── web/ # Web entry point (index.html, manifest.json)
├── assets/icon/ # App icon source
│ └── app_icon.png
├── test/ # Dart tests
│ ├── widget_test.dart # Basic widget smoke test
│ └── time_formatter_test.dart # Timezone + formatting unit tests
├── build_whitelabel.py # Whitelabel build script (Python)
├── requirements.txt # Python deps (Pillow, cairosvg)
├── pubspec.yaml # Flutter package manifest + Dart deps
├── analysis_options.yaml # Dart linter config
├── README.md # Brief project docs
├── PROJECT_STRUCTURE.md # Detailed architecture analysis
├── PROJECT_OVERVIEW.md # This file — setup & onboarding guide
├── .devcontainer/ # VS Code Dev Container config
└── theme.json # Station theme config (colors, images, URLs)
```
## Setup Instructions
### Prerequisites
- Flutter SDK (compatible with Dart ^3.11.5)
- Python 3.10+ (for whitelabel builds)
- Android Studio / Xcode for mobile builds (as applicable)
- Docker (optional, for VS Code Dev Container workflow)
### Quick Start
```bash
# 1. Clone the repository
git clone <repo-url> kryz-go-flutter
cd kryz-go-flutter
# 2. Check out the development branch
git checkout setup/project-onboarding
# 3. Install Flutter dependencies
flutter pub get
# 4. Generate whitelabel config (required before running)
# You must have a theme.json in the project root.
pip install -r requirements.txt
python3 build_whitelabel.py
# 5. Run the app
flutter run # on connected device
flutter run -d chrome # for web debugging
flutter run -d macos # for macOS desktop
```
### Whitelabel Build
The app is fully re-brandable via a single `theme.json` file. This defines station name, callsign, frequency, stream URL, metadata endpoint, color palette, and embedded images.
```bash
# Full build (generates Dart configs, patches native manifests, generates icons)
python3 build_whitelabel.py --theme theme.json
# Dry run (preview what will change)
python3 build_whitelabel.py --theme theme.json --dry-run
# Skip native tool steps (Pillow/cairosvg not installed)
python3 build_whitelabel.py --no-tools
```
The script generates:
- `lib/generated/app_config.dart` — compile-time constants
- `lib/generated/theme_colors.dart` — light + dark theme colors
- `assets/icon/app_icon.png` — from embedded logo
- Patches to `AndroidManifest.xml`, `Info.plist`, `automotive_colors.xml`, and Kotlin sources
### Dev Container
Open the project in VS Code with the Dev Container extension. It provisions a Docker environment with Flutter and Python pre-installed. Post-create steps run `flutter pub get`, set up the Python venv, and install dependencies.
### Naming and Icons
```bash
# Rename the app across all platforms
dart run flutter_name_manager:rename_app --name "KRYZ Go!"
# Regenerate launcher icons
dart run flutter_launcher_icons
```
## Known Issues
1. **No Go backend despite repo name** — The repository is named `kryz-go-flutter` but contains only Flutter/Dart frontend code. The "Go" refers to the app brand name "KRYZ Go!", not the Go programming language.
2. **`webview_flutter` declared but unused** — The `webview_flutter` dependency is in `pubspec.yaml` but is not referenced anywhere in the current code path. Safe to remove if not planned for future use.
3. **`recently_played_list.dart` exists but is not wired into the UI** — The widget is built but not currently used in the main page layout.
4. **Minimal test coverage** — Only two test files exist (`widget_test.dart`, `time_formatter_test.dart`). No integration tests, mock API tests, or casting-specific tests are present.
5. **Hardcoded stream URL in audio handler** — The stream URL is loaded from `AppConfig` (generated by whitelabel), but the fallback and error-handling paths for stream disconnection are limited.
6. **Beta dependency**`just_audio_background` is on a beta version (^0.0.1-beta.17), which may have stability issues on certain Android/iOS versions.
7. **Generated files not committed**`lib/generated/` is not in the repository. Developers must run `build_whitelabel.py` with a `theme.json` before the app will compile.
8. **Android Auto cold-start race condition** — The startup sequence uses `unawaited()` calls that can cause issues if background media services initialize after an Android Auto cold start. A comment in `main.dart` acknowledges this.
## Architecture Overview
### Business Object Pattern
The codebase uses a "Business Object" naming convention for reactive state singletons (all extend `ChangeNotifier`):
| Business Object | Responsibility |
|---|---|
| `StreamingAudioBusinessObject` | Audio playback state (play/stop/buffering) |
| `CastingBusinessObject` | Cast/AirPlay connection state |
| `LiveInfoBusinessObject` | Live metadata + recently-played history |
| `AppThemeBusinessObject` | Theme color definitions |
### Data Flow
```
[Radio Station API] --HTTP--> [LiveInfoBusinessObject] --poll--> [UI: LiveInfoPanel]
--> [AudioHandler: updateMetadata]
--> [RecentlyPlayedList (runtime)]
[Station Stream URL] --stream--> [just_audio Player] --state--> [StreamingAudioBO] --> [UI: Play/Stop]
[Cast/AirPlay] <--platform channels--> [CastingBO] --> [UI: Cast button]
| |
+--native--> MainActivity.kt / iOS native +--> [stops local audio on handoff]
```
### Metadata Polling
- Polls the live-info API every 20 seconds
- Endpoint configured via `theme.json` or `KRYZ_LIVE_INFO_URL` env var
- Response parsed into `LiveInfoSnapshot`: current/next/previous tracks, show schedule, timezone
- Maintains a deduplicated "recently played" list (max 40 items) at runtime
+166
View File
@@ -0,0 +1,166 @@
# KRYZ Go! — Project Structure & Architecture
> Analysis of `kryz-go-flutter` on the `setup/project-onboarding` branch.
## 1. Overview
**KRYZ Go!** is a multi-platform **Flutter** mobile application for the KRYZ LP-FM 98.5 Mariposa community radio station. It streams live audio from the station, displays real-time metadata (now playing, upcoming show), and supports casting to external displays (Chromecast, Android Auto, iOS CarPlay/AirPlay).
**Primary stack:** Flutter + Dart (frontend only). There is no Go backend in this repository — the project name "kryz-go-flutter" refers to the app name "KRYZ Go!", not a Go language backend.
**Target platforms:** Android (including Android Auto), iOS (including CarPlay), macOS, Windows, Linux, and Web.
## 2. Folder Structure
```
kryz-go-flutter/
├── lib/ # Dart application source
│ ├── main.dart # App entry point + MaterialApp + MainPage UI
│ ├── kryz_audio_handler.dart # Background audio handler (audio_service)
│ ├── streaming_audio_business_object.dart # Audio playback controller
│ ├── casting_business_object.dart # Cast/AirPlay state machine (platform channels)
│ ├── live_info_business_object.dart # Live metadata poller + data models
│ ├── live_info_panel.dart # "Live Studio Feed" widget (now/next cards)
│ ├── track_card.dart # Individual track display widget
│ ├── show_card.dart # Show schedule display widget
│ ├── recently_played_list.dart # Recently-played list widget (unused in current UI)
│ ├── app_theme_business_object.dart # Theme colors + light/dark ThemeData
│ ├── time_formatter.dart # Timestamp parsing + timezone conversion
│ └── generated/ # Auto-generated by build_whitelabel.py (not committed)
│ ├── app_config.dart # Compile-time constants (station name, stream URL, etc.)
│ └── theme_colors.dart # Generated AppThemeColors (light + dark)
├── android/ # Android native project
│ └── app/src/main/
│ ├── kotlin/.../MainActivity.kt # Cast framework + MethodChannel bridge
│ ├── kotlin/.../KryzAutoMediaBrowserService.kt # Android Auto media browser
│ ├── kotlin/.../CastOptionsProvider.kt # Cast receiver config
│ ├── AndroidManifest.xml # App manifest + permissions
│ └── res/values/automotive_colors.xml # Android Auto theming
├── ios/ # iOS native project
│ └── Runner/
│ ├── AppDelegate.swift # Flutter engine + CarPlay setup
│ ├── SceneDelegate.swift # Scene lifecycle
│ └── CarPlaySceneDelegate.swift # CarPlay template
├── macos/ # macOS native project (Swift)
├── linux/ # Linux native project (C++)
├── windows/ # Windows native project (C++)
├── web/ # Web entry point (index.html, manifest.json)
├── assets/icon/ # App icon source
│ └── app_icon.png
├── test/ # Dart tests
│ ├── widget_test.dart # Basic widget smoke test
│ └── time_formatter_test.dart # Timezone + formatting unit tests
├── build_whitelabel.py # Whitelabel build script (Python)
├── requirements.txt # Python deps for whitelabel script (Pillow, cairosvg)
├── pubspec.yaml # Flutter package manifest + Dart deps
├── analysis_options.yaml # Dart linter config (flutter_lints)
├── README.md # Brief project docs
├── .devcontainer/ # VS Code Dev Container (Dockerfile + JSON)
└── theme.json # Station theme config (colors, images, stream URLs)
```
## 3. Key Configuration Files
### pubspec.yaml
- **Package:** `kryz_go_flutter`
- **SDK:** Dart ^3.11.5
- **Version:** 1.0.0+1
- **Core dependencies:**
- `just_audio` (^0.10.5) — audio playback engine
- `just_audio_background` (^0.0.1-beta.17) — background audio support
- `audio_service` (^0.18.18) — media session integration (notifications, lock screen)
- `audio_session` (^0.2.2) — audio focus/Duck configuration
- `webview_flutter` (^4.13.0) — embedded WebView (unused in current code path)
- `http` (^1.5.0) — HTTP client for live-info API
- `intl` (^0.20.2) — date/time formatting
- `timezone` (^0.10.1) — timezone-aware datetime handling
- **Dev dependencies:**
- `flutter_name_manager` — rename app across platforms
- `flutter_launcher_icons` — generate platform-specific icons
- `flutter_lints` (^6.0.0) — recommended lint rules
### Android build.gradle.kts
- **Namespace:** `com.example.kryz_go_flutter`
- **Java/Kotlin target:** Java 17
- **Cast dependency:** `play-services-cast-framework:22.1.0`
- **Media router:** `androidx.mediarouter:1.7.0`
- **Media:** `androidx.media:1.7.0`
## 4. Architecture
### Entry Point
`lib/main.dart` initializes timezone data, then runs `MyApp` which creates two top-level business objects: `StreamingAudioBusinessObject` and `CastingBusinessObject`. The `MainPage` widget composes the full UI (header card + live info panel + playback controls).
### Core Modules
#### Audio Playback (`kryz_audio_handler.dart`, `streaming_audio_business_object.dart`)
- `KryzAudioHandler` extends `BaseAudioHandler` (from `audio_service`). It manages a `just_audio.AudioPlayer` instance that streams from a hard-coded `streamUrl` (loaded from `AppConfig`).
- Supports play/stop only (no seek — live stream). Keeps a persistent metadata refresh timer that polls the live-info API every 20 seconds.
- `StreamingAudioBusinessObject` wraps the handler, exposing a simplified `StreamingPlaybackStatus` enum and current track title/artist to the UI.
#### Casting / Remote Playback (`casting_business_object.dart`)
- Communicates with native Android/iOS code through Flutter platform channels (`MethodChannel` + `EventChannel`).
- On Android: integrates with the Google Cast framework (Chromecast) and Android Auto.
- On iOS: integrates with AirPlay.
- Supports showing a device picker, starting a cast stream, stopping playback, and disconnecting.
- Handles the handoff from local playback to remote playback (stops `audio_service` when cast loads).
#### Live Metadata (`live_info_business_object.dart`)
- Polls a metadata endpoint (configurable via `theme.json` or `KRYZ_LIVE_INFO_URL` env var) every 20 seconds.
- Parses the JSON response into `LiveInfoSnapshot` containing:
- `current`, `next`, `previous` track info (title, artist, album, timestamps)
- `currentShow`, `nextShow` (name, description, start/end times)
- `timezone`, `schedulerTime`, `sourceEnabled`
- Maintains a deduplicated "recently played" list (max 40 items) built at runtime.
#### Theme / Whitelabeling (`build_whitelabel.py`, `app_theme_business_object.dart`)
- `theme.json` (not committed) defines station name, callsign, frequency, stream URL, metadata URL, color palette, and embedded images (logo, hero icon).
- `build_whitelabel.py` reads `theme.json` and generates:
- `lib/generated/app_config.dart` — compile-time constants
- `lib/generated/theme_colors.dart` — light + dark `AppThemeColors`
- `assets/icon/app_icon.png` — from embedded logo
- Patches `AndroidManifest.xml`, `Info.plist`, `automotive_colors.xml`, and Kotlin source files
- This design enables re-branding the entire app for different stations by changing a single JSON file.
#### Native Platform Code
- **Android** (`MainActivity.kt`): Full Google Cast framework integration with session management, retry logic, media browser service for Android Auto, and platform channel bridging to Flutter.
- **iOS** (`AppDelegate.swift`, `CarPlaySceneDelegate.swift`): Flutter engine setup and CarPlay template scene delegate.
- **macOS/Linux/Windows**: Standard Flutter shell templates.
### Data Flow
```
[Radio Station API] ──HTTP──> [LiveInfoBusinessObject] ──poll──> [UI: LiveInfoPanel]
└─> [KryzAudioHandler: updateMetadata]
└─> [RecentlyPlayedList (runtime)]
[Station Stream URL] ──stream──> [just_audio Player] ──state──> [StreamingAudioBusinessObject] ──> [UI: Play/Stop buttons]
[Cast/AirPlay] ◄──platform channels──> [CastingBusinessObject] ──> [UI: Cast button]
│ │
└──native──> MainActivity.kt / iOS native └──> [stops local audio on handoff]
```
## 5. Business Object Pattern
The codebase uses a "Business Object" naming convention for stateful singletons:
- `StreamingAudioBusinessObject` — audio playback state
- `CastingBusinessObject` — cast/AirPlay connection state
- `LiveInfoBusinessObject` — live metadata + recently-played history
- `AppThemeBusinessObject` — theme color definitions
All extend `ChangeNotifier`, providing reactive state updates to Flutter widgets via `addListener`/`notifyListeners`.
## 6. Development Environment
- **Dev Container:** VS Code remote container with Flutter + Python venv. Extensions: Flutter, TypeScript.
- **Post-create:** `flutter pub get`, Python venv + deps install, Claude CLI install.
- **Build:** `flutter build <platform>` or `flutter run <platform>` for debug.
- **Whitelabel build:** `python3 build_whitelabel.py [--theme theme.json] [--dry-run] [--no-tools]`
## 7. Test Coverage
- `test/widget_test.dart` — basic smoke test verifying app title and playback buttons render.
- `test/time_formatter_test.dart` — comprehensive tests for timezone conversion, naive vs. explicit UTC timestamps, and time-window formatting.
No integration tests, mock API tests, or casting-specific tests currently exist.
-67
View File
@@ -31,70 +31,3 @@ dart run flutter_name_manager:rename_app --name "KRYZ Go!"
dart run flutter_launcher_icons dart run flutter_launcher_icons
``` ```
## CI/CD Pipeline
This project uses **Gitea Actions** for continuous integration and release automation.
### Pipeline Overview
```
┌─────────┐ ┌────────┐ ┌──────────────┐ ┌──────────────┐
│ Lint │───►│ Test │───►│ Build Android │ │ Build iOS │
│ │ │ │ │ (APK + AAB) │ │ (self-hosted)│
└─────────┘ └────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
dart fmt flutter test flutter build ios
flutter analyze (--no-codesign fallback)
```
### Workflows
#### CI (`.gitea/workflows/ci.yml`)
Triggers on:
- Push to `main` or `develop`
- Pull requests targeting `main`
Stages:
1. **Lint**`dart format --fix` + `flutter analyze --fatal-warnings`
2. **Test**`flutter test` (coverage on main branch pushes)
3. **Build Android** — APK + AAB release builds with artifact upload
4. **Build iOS**`flutter build ios --release --no-codesign` (runs on self-hosted macOS runner)
#### Release (`.gitea/workflows/release.yml`)
Triggers on:
- Git tags matching `v*` (e.g., `v1.0.0`)
Stages:
1. **Lint**`dart format --set-exit-if-changed` + `flutter analyze --fatal-warnings`
2. **Test**`flutter test`
3. **Build Android Release** — Signed APK + AAB
4. **Build iOS Release** — Signed IPA (or `--no-codesign` fallback)
5. **Publish Release** — Creates Gitea release + uploads artifacts
### Configuration
| Secret | Required | Description |
|---|---|---|
| `ANDROID_KEYSTORE_BASE64` | No | Base64-encoded Android release keystore |
| `ANDROID_KEYSTORE_PASS` | No | Keystore password |
| `ANDROID_KEY_ALIAS` | No | Key alias |
| `ANDROID_KEY_PASS` | No | Key password |
| `APPLE_CERTIFICATE_P12` | No | Base64-encoded Apple signing certificate |
| `APPLE_CERTIFICATE_PASS` | No | Certificate password |
| `APPLE_PROVISION_PROFILE` | No | Base64-encoded provisioning profile |
| `GITEA_TOKEN` | Yes (Release) | Token for creating Gitea releases |
Without signing secrets, builds use debug keys or `--no-codesign`.
### CI Status
![CI](https://git.westerntechnologies.duckdns.org/kfj001/kryz-go-flutter/actions/workflows/ci.yml/badge.svg)
### Troubleshooting
- **Stubs missing**: The `ci_stub_gen.py` script auto-generates `lib/generated/*.dart` stubs when `theme.json` is absent.
- **macOS runner unavailable**: The iOS stage uses `self-hosted` label. Ensure a macOS Gitea runner is registered.
- **Format checks**: CI uses `dart format --fix` (auto-fix). Release uses `--set-exit-if-changed` (gate).
+2 -23
View File
@@ -12,13 +12,6 @@ dependencies {
implementation("androidx.media:media:1.7.0") implementation("androidx.media:media:1.7.0")
} }
// Load release signing properties if key.properties exists (set by CI pipeline)
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = java.util.Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.example.kryz_go_flutter" namespace = "com.example.kryz_go_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -44,27 +37,13 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// Use release signing if key.properties exists, otherwise fall back to debug. // TODO: Add your own signing config for the release build.
if (keystorePropertiesFile.exists()) { // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
} }
}
} }
flutter { flutter {
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Generate stub lib/generated/*.dart files so flutter analyze passes
before the whitelabel build script has been run.
This is only needed for CI lint gates. The real build generates these
from theme.json via build_whitelabel.py.
"""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
GENERATED_DIR = os.path.join(PROJECT_ROOT, "lib", "generated")
DEFAULT_APP_CONFIG = '''// GENERATED STUB — DO NOT EDIT.
// This file is replaced by `python3 build_whitelabel.py` during actual builds.
// Exists only so that `flutter analyze` passes before whitelabel generation.
class AppConfig {
static const String callsign = 'KRYZ';
static const String appName = 'KRYZ Go';
static const String tagline = '';
static const String frequency = '';
static const String streamUrl = '';
static const String? metadataUrl = null;
static const String defaultTitle = 'KRYZ Live Stream';
static const String defaultArtist = 'KRYZ Radio';
}
'''
DEFAULT_THEME_COLORS = '''// GENERATED STUB — DO NOT EDIT.
// This file is replaced by `python3 build_whitelabel.py` during actual builds.
// Exists only so that `flutter analyze` passes before whitelabel generation.
import 'package:flutter/material.dart';
import 'package:kryz_go_flutter/app_theme_business_object.dart';
class GeneratedThemeColors {
static const AppThemeColors lightColors = AppThemeColors(
headingColor: Color(0xFF1E293B),
accentColor: Color(0xFF6366F1),
scaffoldGradientStart: Color(0xFFF8F9FA),
scaffoldGradientEnd: Color(0xFFE9ECEF),
shellSurface: Color(0xFFFDFDFD),
shellBorder: Color(0xFFDEE2E6),
mutedText: Color(0xFF6C757D),
textOnDark: Color(0xFFFFFFFF),
textOnDarkSecondary: Color(0xFFE9ECEF),
controlsGradientStart: Color(0xFF1E293B),
controlsGradientEnd: Color(0xFF334155),
controlsShadow: Color(0x24000000),
bodyText: Color(0xFF333333),
cardSurface: Color(0x14FFFFFF),
cardBorder: Color(0x1FFFFFFF),
successColor: Color(0xFF28A745),
dangerColor: Color(0xFFDC3545),
);
static const AppThemeColors darkColors = AppThemeColors(
headingColor: Color(0xFF8B949E),
accentColor: Color(0xFF7C83FF),
scaffoldGradientStart: Color(0xFF0D1117),
scaffoldGradientEnd: Color(0xFF161B22),
shellSurface: Color(0xFF161B22),
shellBorder: Color(0xFF30363D),
mutedText: Color(0xFF6C757D),
textOnDark: Color(0xFFFFFFFF),
textOnDarkSecondary: Color(0xFFE9ECEF),
controlsGradientStart: Color(0xFF161B22),
controlsGradientEnd: Color(0xFF1E293B),
controlsShadow: Color(0x37000000),
bodyText: Color(0xFFC9D1D9),
cardSurface: Color(0x14FFFFFF),
cardBorder: Color(0x1FFFFFFF),
successColor: Color(0xFF28A745),
dangerColor: Color(0xFFDC3545),
);
}
'''
def main():
os.makedirs(GENERATED_DIR, exist_ok=True)
paths = [
(os.path.join(GENERATED_DIR, "app_config.dart"), DEFAULT_APP_CONFIG),
(os.path.join(GENERATED_DIR, "theme_colors.dart"), DEFAULT_THEME_COLORS),
]
for path, content in paths:
with open(path, "w") as f:
f.write(content)
print(f"Generated {path}")
print(f"Generated stubs in {GENERATED_DIR}")
if __name__ == "__main__":
main()
-16
View File
@@ -25,52 +25,36 @@ class AppThemeColors {
/// Website: `primary` → headings (h1h6) /// Website: `primary` → headings (h1h6)
final Color headingColor; final Color headingColor;
/// Website: `accent` → links & accent highlights /// Website: `accent` → links & accent highlights
final Color accentColor; final Color accentColor;
/// Page background gradient start — website: `background` (cream) /// Page background gradient start — website: `background` (cream)
final Color scaffoldGradientStart; final Color scaffoldGradientStart;
/// Page background gradient end — website: `light` /// Page background gradient end — website: `light`
final Color scaffoldGradientEnd; final Color scaffoldGradientEnd;
/// Top header card surface /// Top header card surface
final Color shellSurface; final Color shellSurface;
/// Top header card border /// Top header card border
final Color shellBorder; final Color shellBorder;
/// Website: `medium` → muted / secondary text /// Website: `medium` → muted / secondary text
final Color mutedText; final Color mutedText;
/// Website: `white` → text on dark surfaces /// Website: `white` → text on dark surfaces
final Color textOnDark; final Color textOnDark;
/// Website: `light` → secondary text on dark surfaces /// Website: `light` → secondary text on dark surfaces
final Color textOnDarkSecondary; final Color textOnDarkSecondary;
/// Controls bar / card surface gradient start — website: `primary_dark` /// Controls bar / card surface gradient start — website: `primary_dark`
final Color controlsGradientStart; final Color controlsGradientStart;
/// Controls bar / card surface gradient end — website: `primary_dark` /// Controls bar / card surface gradient end — website: `primary_dark`
final Color controlsGradientEnd; final Color controlsGradientEnd;
/// Shadow color for elevated surfaces /// Shadow color for elevated surfaces
final Color controlsShadow; final Color controlsShadow;
/// Website: `text` → body text /// Website: `text` → body text
final Color bodyText; final Color bodyText;
/// Inner card background overlay — derived from `primary_dark` / `primary_light` /// Inner card background overlay — derived from `primary_dark` / `primary_light`
final Color cardSurface; final Color cardSurface;
/// Inner card border — derived from `primary_dark` / `primary_light` /// Inner card border — derived from `primary_dark` / `primary_light`
final Color cardBorder; final Color cardBorder;
/// Website: `success` → live indicator, success states /// Website: `success` → live indicator, success states
final Color successColor; final Color successColor;
/// Website: `danger` → error states /// Website: `danger` → error states
final Color dangerColor; final Color dangerColor;
} }
+14 -18
View File
@@ -28,7 +28,10 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
title: AppConfig.defaultTitle, title: AppConfig.defaultTitle,
artist: AppConfig.defaultArtist, artist: AppConfig.defaultArtist,
album: 'Live', album: 'Live',
extras: <String, dynamic>{'isLive': true, 'seekable': false}, extras: <String, dynamic>{
'isLive': true,
'seekable': false,
},
); );
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -80,7 +83,10 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
try { try {
if (!_isSourceLoaded) { if (!_isSourceLoaded) {
await _player.setAudioSource( await _player.setAudioSource(
AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value), AudioSource.uri(
Uri.parse(streamUrl),
tag: mediaItem.value,
),
); );
_isSourceLoaded = true; _isSourceLoaded = true;
} }
@@ -153,8 +159,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
} }
void _onPlayerStateChanged(PlayerState state) { void _onPlayerStateChanged(PlayerState state) {
final AudioProcessingState processingState = final AudioProcessingState processingState = switch (state.processingState) {
switch (state.processingState) {
ProcessingState.idle => AudioProcessingState.idle, ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading, ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.buffering => AudioProcessingState.buffering,
@@ -162,26 +167,17 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
ProcessingState.completed => AudioProcessingState.completed, ProcessingState.completed => AudioProcessingState.completed,
}; };
final bool isPlaying = final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
state.playing && state.processingState == ProcessingState.ready; final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
final bool isBuffering = (_playRequested && !state.playing && state.processingState != ProcessingState.completed);
(state.playing && state.processingState != ProcessingState.ready) ||
(_playRequested &&
!state.playing &&
state.processingState != ProcessingState.completed);
playbackState.add( playbackState.add(
playbackState.value.copyWith( playbackState.value.copyWith(
controls: <MediaControl>[ controls: <MediaControl>[
if (isPlaying || isBuffering) if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
MediaControl.stop
else
MediaControl.play,
], ],
systemActions: const <MediaAction>{}, systemActions: const <MediaAction>{},
processingState: isBuffering processingState: isBuffering ? AudioProcessingState.buffering : processingState,
? AudioProcessingState.buffering
: processingState,
playing: isPlaying, playing: isPlaying,
), ),
); );
+1 -5
View File
@@ -23,16 +23,12 @@ class LiveInfoBusinessObject extends ChangeNotifier {
if (override != null && override.isNotEmpty) { if (override != null && override.isNotEmpty) {
return override; return override;
} }
const envUrl = String.fromEnvironment( const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: '');
'KRYZ_LIVE_INFO_URL',
defaultValue: '',
);
if (envUrl.isNotEmpty) { if (envUrl.isNotEmpty) {
return envUrl; return envUrl;
} }
return AppConfig.metadataUrl ?? ''; return AppConfig.metadataUrl ?? '';
} }
final Duration requestTimeout; final Duration requestTimeout;
Timer? _pollTimer; Timer? _pollTimer;
+2 -1
View File
@@ -458,7 +458,8 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
label: const Text('Stop'), label: const Text('Stop'),
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _casting.status == CastingStatus.connecting onPressed:
_casting.status == CastingStatus.connecting
? null ? null
: _toggleCasting, : _toggleCasting,
icon: Icon(_castingIcon()), icon: Icon(_castingIcon()),
+7 -2
View File
@@ -39,7 +39,9 @@ class ShowCard extends StatelessWidget {
'$label: ${show?.name ?? 'No scheduled show'}', '$label: ${show?.name ?? 'No scheduled show'}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: titleColor, color: titleColor,
), ),
@@ -47,7 +49,10 @@ class ShowCard extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
formatStartingTime(show?.starts, sourceTimezone: sourceTimezone), formatStartingTime(
show?.starts,
sourceTimezone: sourceTimezone,
),
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: titleColor), ).textTheme.labelSmall?.copyWith(color: titleColor),
+7 -14
View File
@@ -35,22 +35,18 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
return; return;
} }
_audioHandlerInitFuture ??= _audioHandlerInitFuture ??= AudioService.init(
AudioService.init(
builder: () => KryzAudioHandler(), builder: () => KryzAudioHandler(),
config: const AudioServiceConfig( config: const AudioServiceConfig(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: androidNotificationChannelName: '${AppConfig.appName} Audio Playback',
'${AppConfig.appName} Audio Playback',
androidNotificationOngoing: true, androidNotificationOngoing: true,
androidStopForegroundOnPause: true, androidStopForegroundOnPause: true,
), ),
) ).then((handler) {
.then((handler) {
_audioHandler = handler; _audioHandler = handler;
return handler; return handler;
}) }).catchError((error) {
.catchError((error) {
_audioHandlerInitFuture = null; _audioHandlerInitFuture = null;
throw error; throw error;
}); });
@@ -150,12 +146,9 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
return; return;
} }
final title = item.title.trim().isEmpty final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title;
? AppConfig.defaultTitle final subtitle =
: item.title; (item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim();
final subtitle = (item.artist ?? '').trim().isEmpty
? AppConfig.defaultArtist
: item.artist!.trim();
if (title == _currentTitle && subtitle == _currentSubtitle) { if (title == _currentTitle && subtitle == _currentSubtitle) {
return; return;
+2 -3
View File
@@ -120,9 +120,8 @@ DateTime? _tryParseServerDateTime(String value) {
final int second = parsePart(6); final int second = parsePart(6);
final String fraction = (match.group(7) ?? '').padRight(6, '0'); final String fraction = (match.group(7) ?? '').padRight(6, '0');
final int microseconds = fraction.isEmpty final int microseconds =
? 0 fraction.isEmpty ? 0 : int.parse(fraction.substring(0, 6));
: int.parse(fraction.substring(0, 6));
return DateTime( return DateTime(
year, year,
+6 -2
View File
@@ -47,7 +47,9 @@ class TrackCard extends StatelessWidget {
title, title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: titleColor, color: titleColor,
), ),
@@ -64,7 +66,9 @@ class TrackCard extends StatelessWidget {
const Spacer(), const Spacer(),
Text( Text(
window, window,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: titleColor, color: titleColor,
), ),
+8 -13
View File
@@ -54,19 +54,18 @@ void main() {
test('does not double-convert timestamps with explicit UTC designator', () { test('does not double-convert timestamps with explicit UTC designator', () {
const String utcStamp = '2026-05-15T03:00:00Z'; const String utcStamp = '2026-05-15T03:00:00Z';
final String expected = DateFormat( final String expected = DateFormat('jm').format(DateTime.parse(utcStamp).toLocal());
'jm',
).format(DateTime.parse(utcStamp).toLocal());
expect( expect(
formatStartingTime(utcStamp, sourceTimezone: 'America/Los_Angeles'), formatStartingTime(
utcStamp,
sourceTimezone: 'America/Los_Angeles',
),
expected, expected,
); );
}); });
test( test('keeps wall-clock time when local matches source zone for naive input', () {
'keeps wall-clock time when local matches source zone for naive input',
() {
const String sourceTime = '2026-05-14 20:00:00'; const String sourceTime = '2026-05-14 20:00:00';
const String sourceZone = 'America/Los_Angeles'; const String sourceZone = 'America/Los_Angeles';
@@ -88,17 +87,13 @@ void main() {
formatStartingTime(sourceTime, sourceTimezone: sourceZone), formatStartingTime(sourceTime, sourceTimezone: sourceZone),
expected, expected,
); );
}, });
);
}); });
group('formatTimeWindow', () { group('formatTimeWindow', () {
test('returns mm:ss style duration from server times', () { test('returns mm:ss style duration from server times', () {
expect( expect(
formatTimeWindow( formatTimeWindow('2026-05-15 05:18:46.000000', '2026-05-15 05:23:21.000000'),
'2026-05-15 05:18:46.000000',
'2026-05-15 05:23:21.000000',
),
'04:35', '04:35',
); );
}); });
+2 -8
View File
@@ -16,13 +16,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('KRYZ Go!'), findsWidgets); expect(find.text('KRYZ Go!'), findsWidgets);
expect( expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget);
find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget);
findsOneWidget,
);
expect(
find.widgetWithIcon(ElevatedButton, Icons.stop_rounded),
findsOneWidget,
);
}); });
} }