diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5d8d4da --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,254 @@ +# 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: "stable" + +jobs: + # ── Stage 1: Lint & static analysis ────────────────────────────── + lint: + name: Lint + runs-on: [self-hosted, linux] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + run: | + # Direct Flutter SDK download — subosito/flutter-action queries + # GitHub internal APIs that are unavailable on self-hosted Gitea runners. + curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" \ + -o flutter.tar.xz + tar -xf flutter.tar.xz + export PATH="$PWD/flutter/bin:$PATH" + flutter --version + echo "$PWD/flutter/bin" >> "$GITHUB_PATH" || echo "$PWD/flutter/bin" >> "$HOME/.env_path" + + - 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: [self-hosted, linux] + needs: [lint] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + run: | + # Direct Flutter SDK download — self-hosted Gitea runner (Linux) + curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" \ + -o flutter.tar.xz + tar -xf flutter.tar.xz + export PATH="$PWD/flutter/bin:$PATH" + flutter --version + echo "$PWD/flutter/bin" >> "$GITHUB_PATH" || echo "$PWD/flutter/bin" >> "$HOME/.env_path" + + - 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: "Note: coverage artifact storage not configured" + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' + run: | + echo "WARNING: Gitea Actions artifact storage is not configured." + echo "Coverage report available at: coverage/lcov.info" + + + # ── 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 + run: | + # Direct Temurin JDK download — actions/setup-java queries GitHub APIs + curl -fsSL "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz" \ + -o jdk.tar.gz + mkdir -p "$HOME/jdk" + tar -xzf jdk.tar.gz -C "$HOME/jdk" --strip-components=1 + export JAVA_HOME="$HOME/jdk" + export PATH="$JAVA_HOME/bin:$PATH" + java -version + echo "JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV" || export JAVA_HOME="$JAVA_HOME" + echo "$JAVA_HOME/bin" >> "$GITHUB_PATH" || export PATH="$JAVA_HOME/bin:$PATH" + + - name: Setup Flutter + run: | + # Direct Flutter SDK download — Linux build runner + curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" \ + -o flutter.tar.xz + tar -xf flutter.tar.xz + export PATH="$PWD/flutter/bin:$PATH" + flutter --version + echo "$PWD/flutter/bin" >> "$GITHUB_PATH" || echo "$PWD/flutter/bin" >> "$HOME/.env_path" + + - name: Install Android SDK command-line tools + run: | + export ANDROID_HOME="${HOME}/android-sdk" + mkdir -p "${ANDROID_HOME}/cmdline-tools" + curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" -o cmdline-tools.zip + unzip -q cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" + mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" + export PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${PATH}" + yes | sdkmanager --licenses >/dev/null 2>&1 || true + sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" + echo "ANDROID_HOME=${ANDROID_HOME}" >> "$GITHUB_ENV" || echo "ANDROID_HOME=${ANDROID_HOME}" >> "$HOME/.env" + echo "${ANDROID_HOME}/cmdline-tools/latest/bin" >> "$GITHUB_PATH" || export PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${PATH}" + echo "${ANDROID_HOME}/platform-tools" >> "$GITHUB_PATH" || export PATH="${ANDROID_HOME}/platform-tools:${PATH}" + + - name: Pre-cache Android platform artifacts + run: flutter precache --android + + - name: Accept Android SDK licenses + run: | + yes | sdkmanager --licenses || true + + - name: Install Python dependencies + run: | + # Use system Python — actions/setup-python queries GitHub APIs + if command -v python3 &>/dev/null; then + python3 -m pip install --upgrade pip 2>/dev/null || true + if [ -f requirements.txt ]; then + python3 -m pip install -r requirements.txt + fi + else + echo "WARNING: Python3 not found, skipping pip install" + fi + + - 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 <> "$GITHUB_PATH" || echo "$PWD/flutter/bin" >> "$HOME/.env_path" + + - 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: "Note: iOS artifact storage not configured" + run: | + echo "WARNING: Gitea Actions artifact storage is not configured on this instance." + echo "iOS build outputs available in runner workspace:" + echo " build/ios/iphoneos/" + echo "Configure artifact storage in Gitea Actions admin settings to enable uploads." diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..2eae14d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,277 @@ +# 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: "stable" + +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 < "$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" diff --git a/README.md b/README.md index 51e198b..41bbbc8 100755 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Will build a release build for ``. ## Running in Debug mode With a device or devices connected: -`flutter run ` +`flutter run ` Will execute the application in the debugging sandbox. @@ -31,3 +31,70 @@ dart run flutter_name_manager:rename_app --name "KRYZ Go!" 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). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9a4b8cf..f65965b 100755 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") id("kotlin-android") @@ -12,6 +15,13 @@ dependencies { 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 = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.example.kryz_go_flutter" compileSdk = flutter.compileSdkVersion @@ -23,7 +33,7 @@ android { } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = "17" } defaultConfig { @@ -37,11 +47,25 @@ android { versionName = flutter.versionName } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Use release signing if key.properties exists, otherwise fall back to debug. + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } else { + signingConfig = signingConfigs.getByName("debug") + } } } } diff --git a/ci_stub_gen.py b/ci_stub_gen.py new file mode 100644 index 0000000..0464674 --- /dev/null +++ b/ci_stub_gen.py @@ -0,0 +1,99 @@ +#!/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() diff --git a/lib/app_theme_business_object.dart b/lib/app_theme_business_object.dart index a37b335..3b9ffa4 100755 --- a/lib/app_theme_business_object.dart +++ b/lib/app_theme_business_object.dart @@ -25,36 +25,52 @@ class AppThemeColors { /// Website: `primary` → headings (h1–h6) final Color headingColor; + /// Website: `accent` → links & accent highlights final Color accentColor; + /// Page background gradient start — website: `background` (cream) final Color scaffoldGradientStart; + /// Page background gradient end — website: `light` final Color scaffoldGradientEnd; + /// Top header card surface final Color shellSurface; + /// Top header card border final Color shellBorder; + /// Website: `medium` → muted / secondary text final Color mutedText; + /// Website: `white` → text on dark surfaces final Color textOnDark; + /// Website: `light` → secondary text on dark surfaces final Color textOnDarkSecondary; + /// Controls bar / card surface gradient start — website: `primary_dark` final Color controlsGradientStart; + /// Controls bar / card surface gradient end — website: `primary_dark` final Color controlsGradientEnd; + /// Shadow color for elevated surfaces final Color controlsShadow; + /// Website: `text` → body text final Color bodyText; + /// Inner card background overlay — derived from `primary_dark` / `primary_light` final Color cardSurface; + /// Inner card border — derived from `primary_dark` / `primary_light` final Color cardBorder; + /// Website: `success` → live indicator, success states final Color successColor; + /// Website: `danger` → error states final Color dangerColor; } diff --git a/lib/casting_business_object.dart b/lib/casting_business_object.dart index b60e7f9..8f1350d 100755 --- a/lib/casting_business_object.dart +++ b/lib/casting_business_object.dart @@ -20,7 +20,7 @@ class CastingBusinessObject extends ChangeNotifier { CastingPlaybackStatus _playbackStatus = CastingPlaybackStatus.unknown; String? _connectedDeviceName; String? _lastError; - + /// Callback for when native side requests local audio to stop VoidCallback? onStopLocalAudioRequested; diff --git a/lib/kryz_audio_handler.dart b/lib/kryz_audio_handler.dart index 0034717..bf43d52 100755 --- a/lib/kryz_audio_handler.dart +++ b/lib/kryz_audio_handler.dart @@ -28,10 +28,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler { title: AppConfig.defaultTitle, artist: AppConfig.defaultArtist, album: 'Live', - extras: { - 'isLive': true, - 'seekable': false, - }, + extras: {'isLive': true, 'seekable': false}, ); final AudioPlayer _player = AudioPlayer(); @@ -83,10 +80,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler { try { if (!_isSourceLoaded) { await _player.setAudioSource( - AudioSource.uri( - Uri.parse(streamUrl), - tag: mediaItem.value, - ), + AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value), ); _isSourceLoaded = true; } @@ -159,25 +153,35 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler { } void _onPlayerStateChanged(PlayerState state) { - final AudioProcessingState processingState = switch (state.processingState) { - ProcessingState.idle => AudioProcessingState.idle, - ProcessingState.loading => AudioProcessingState.loading, - ProcessingState.buffering => AudioProcessingState.buffering, - ProcessingState.ready => AudioProcessingState.ready, - ProcessingState.completed => AudioProcessingState.completed, - }; + final AudioProcessingState processingState = + switch (state.processingState) { + ProcessingState.idle => AudioProcessingState.idle, + ProcessingState.loading => AudioProcessingState.loading, + ProcessingState.buffering => AudioProcessingState.buffering, + ProcessingState.ready => AudioProcessingState.ready, + ProcessingState.completed => AudioProcessingState.completed, + }; - final bool isPlaying = state.playing && state.processingState == ProcessingState.ready; - final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) || - (_playRequested && !state.playing && state.processingState != ProcessingState.completed); + final bool isPlaying = + state.playing && state.processingState == ProcessingState.ready; + final bool isBuffering = + (state.playing && state.processingState != ProcessingState.ready) || + (_playRequested && + !state.playing && + state.processingState != ProcessingState.completed); playbackState.add( playbackState.value.copyWith( controls: [ - if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play, + if (isPlaying || isBuffering) + MediaControl.stop + else + MediaControl.play, ], systemActions: const {}, - processingState: isBuffering ? AudioProcessingState.buffering : processingState, + processingState: isBuffering + ? AudioProcessingState.buffering + : processingState, playing: isPlaying, ), ); @@ -220,4 +224,4 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler { // Keep existing metadata if live-info fetch fails. } } -} \ No newline at end of file +} diff --git a/lib/live_info_business_object.dart b/lib/live_info_business_object.dart index 6e4e6be..6c0293d 100755 --- a/lib/live_info_business_object.dart +++ b/lib/live_info_business_object.dart @@ -11,8 +11,8 @@ class LiveInfoBusinessObject extends ChangeNotifier { String? endpoint, int refreshIntervalSeconds = 20, this.requestTimeout = const Duration(seconds: 8), - }) : _endpointOverride = endpoint, - _refreshIntervalSeconds = refreshIntervalSeconds; + }) : _endpointOverride = endpoint, + _refreshIntervalSeconds = refreshIntervalSeconds; /// Explicit endpoint passed at construction, if any. final String? _endpointOverride; @@ -23,12 +23,16 @@ class LiveInfoBusinessObject extends ChangeNotifier { if (override != null && override.isNotEmpty) { return override; } - const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: ''); + const envUrl = String.fromEnvironment( + 'KRYZ_LIVE_INFO_URL', + defaultValue: '', + ); if (envUrl.isNotEmpty) { return envUrl; } return AppConfig.metadataUrl ?? ''; } + final Duration requestTimeout; Timer? _pollTimer; diff --git a/lib/main.dart b/lib/main.dart index 61125d6..80a0e80 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -139,7 +139,7 @@ class _MainPageState extends State with WidgetsBindingObserver { _liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.start(); _playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall); - + // When native code requests local audio to stop (cast media loaded) _casting.onStopLocalAudioRequested = () { unawaited(_audio.stop()); @@ -458,8 +458,7 @@ class _MainPageState extends State with WidgetsBindingObserver { label: const Text('Stop'), ), ElevatedButton.icon( - onPressed: - _casting.status == CastingStatus.connecting + onPressed: _casting.status == CastingStatus.connecting ? null : _toggleCasting, icon: Icon(_castingIcon()), diff --git a/lib/show_card.dart b/lib/show_card.dart index 48f11b3..004ae41 100755 --- a/lib/show_card.dart +++ b/lib/show_card.dart @@ -39,9 +39,7 @@ class ShowCard extends StatelessWidget { '$label: ${show?.name ?? 'No scheduled show'}', maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: titleColor, ), @@ -49,10 +47,7 @@ class ShowCard extends StatelessWidget { ), const SizedBox(width: 8), Text( - formatStartingTime( - show?.starts, - sourceTimezone: sourceTimezone, - ), + formatStartingTime(show?.starts, sourceTimezone: sourceTimezone), style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: titleColor), diff --git a/lib/streaming_audio_business_object.dart b/lib/streaming_audio_business_object.dart index b0eb805..e02c178 100755 --- a/lib/streaming_audio_business_object.dart +++ b/lib/streaming_audio_business_object.dart @@ -35,21 +35,25 @@ class StreamingAudioBusinessObject extends ChangeNotifier { return; } - _audioHandlerInitFuture ??= AudioService.init( - builder: () => KryzAudioHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', - androidNotificationChannelName: '${AppConfig.appName} Audio Playback', - androidNotificationOngoing: true, - androidStopForegroundOnPause: true, - ), - ).then((handler) { - _audioHandler = handler; - return handler; - }).catchError((error) { - _audioHandlerInitFuture = null; - throw error; - }); + _audioHandlerInitFuture ??= + AudioService.init( + builder: () => KryzAudioHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', + androidNotificationChannelName: + '${AppConfig.appName} Audio Playback', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + ), + ) + .then((handler) { + _audioHandler = handler; + return handler; + }) + .catchError((error) { + _audioHandlerInitFuture = null; + throw error; + }); await _audioHandlerInitFuture; } @@ -146,9 +150,12 @@ class StreamingAudioBusinessObject extends ChangeNotifier { return; } - final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title; - final subtitle = - (item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim(); + final title = item.title.trim().isEmpty + ? AppConfig.defaultTitle + : item.title; + final subtitle = (item.artist ?? '').trim().isEmpty + ? AppConfig.defaultArtist + : item.artist!.trim(); if (title == _currentTitle && subtitle == _currentSubtitle) { return; diff --git a/lib/time_formatter.dart b/lib/time_formatter.dart index 7260be6..429efc6 100755 --- a/lib/time_formatter.dart +++ b/lib/time_formatter.dart @@ -120,8 +120,9 @@ DateTime? _tryParseServerDateTime(String value) { final int second = parsePart(6); final String fraction = (match.group(7) ?? '').padRight(6, '0'); - final int microseconds = - fraction.isEmpty ? 0 : int.parse(fraction.substring(0, 6)); + final int microseconds = fraction.isEmpty + ? 0 + : int.parse(fraction.substring(0, 6)); return DateTime( year, diff --git a/lib/track_card.dart b/lib/track_card.dart index 5180b77..6b072e7 100755 --- a/lib/track_card.dart +++ b/lib/track_card.dart @@ -47,9 +47,7 @@ class TrackCard extends StatelessWidget { title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith( + style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: titleColor, ), @@ -66,9 +64,7 @@ class TrackCard extends StatelessWidget { const Spacer(), Text( window, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith( + style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, color: titleColor, ), diff --git a/test/time_formatter_test.dart b/test/time_formatter_test.dart index 49a0bff..37b5819 100755 --- a/test/time_formatter_test.dart +++ b/test/time_formatter_test.dart @@ -54,46 +54,51 @@ void main() { test('does not double-convert timestamps with explicit UTC designator', () { const String utcStamp = '2026-05-15T03:00:00Z'; - final String expected = DateFormat('jm').format(DateTime.parse(utcStamp).toLocal()); + final String expected = DateFormat( + 'jm', + ).format(DateTime.parse(utcStamp).toLocal()); expect( - formatStartingTime( - utcStamp, - sourceTimezone: 'America/Los_Angeles', - ), + formatStartingTime(utcStamp, sourceTimezone: 'America/Los_Angeles'), expected, ); }); - test('keeps wall-clock time when local matches source zone for naive input', () { - const String sourceTime = '2026-05-14 20:00:00'; - const String sourceZone = 'America/Los_Angeles'; + test( + 'keeps wall-clock time when local matches source zone for naive input', + () { + const String sourceTime = '2026-05-14 20:00:00'; + const String sourceZone = 'America/Los_Angeles'; - final tz.TZDateTime source = tz.TZDateTime( - tz.getLocation(sourceZone), - 2026, - 5, - 14, - 20, - ); - final String expected = DateFormat('jm').format( - DateTime.fromMillisecondsSinceEpoch( - source.millisecondsSinceEpoch, - isUtc: true, - ).toLocal(), - ); + final tz.TZDateTime source = tz.TZDateTime( + tz.getLocation(sourceZone), + 2026, + 5, + 14, + 20, + ); + final String expected = DateFormat('jm').format( + DateTime.fromMillisecondsSinceEpoch( + source.millisecondsSinceEpoch, + isUtc: true, + ).toLocal(), + ); - expect( - formatStartingTime(sourceTime, sourceTimezone: sourceZone), - expected, - ); - }); + expect( + formatStartingTime(sourceTime, sourceTimezone: sourceZone), + expected, + ); + }, + ); }); group('formatTimeWindow', () { test('returns mm:ss style duration from server times', () { expect( - formatTimeWindow('2026-05-15 05:18:46.000000', '2026-05-15 05:23:21.000000'), + formatTimeWindow( + '2026-05-15 05:18:46.000000', + '2026-05-15 05:23:21.000000', + ), '04:35', ); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index e1d3899..fd22e51 100755 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,7 +16,13 @@ void main() { await tester.pump(); expect(find.text('KRYZ Go!'), findsWidgets); - expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget); - expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget); + expect( + find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), + findsOneWidget, + ); + expect( + find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), + findsOneWidget, + ); }); }