Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea37f63aa9 | ||
|
|
3d38ae6453 | ||
|
|
6679a1a00c | ||
|
|
0257937859 | ||
|
|
0e131e1db2 | ||
|
|
137815ad25 | ||
|
|
c299d46f91 | ||
|
|
b2bfb6d84d | ||
|
|
7b04b2f9cd | ||
|
|
7f060f381a | ||
|
|
4779406d62 | ||
|
|
58ea55f359 | ||
|
|
f069fcdbfb | ||
|
|
9706cc9a6d | ||
|
|
ea1bc88a70 |
@@ -4,7 +4,7 @@ ENV PATH="$PATH:/opt/flutter/bin"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
||||
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils python3 pip pipx lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Become non-root
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kryz-go",
|
||||
"mounts": [
|
||||
"source=/dev/dri,target=/dev/dri,type=bind"
|
||||
// "source=/dev/dri,target=/dev/dri,type=bind"
|
||||
],
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
@@ -14,5 +14,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "flutter doctor -v"
|
||||
"postCreateCommand": "flutter pub get && python3 -m venv .venv && . .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && curl -fsSL https://claude.ai/install.sh | bash",
|
||||
"postStartCommand": ".venv/bin/activate",
|
||||
"containerEnv": {
|
||||
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
|
||||
"ANTHROPIC_API_KEY": "lmstudio",
|
||||
"ANTHROPIC_BASE_MODEL": "qwen/qwen3.6-27b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
# 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: 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}/cmdline-tools/latest/bin" >> "${GITHUB_PATH}"
|
||||
echo "${ANDROID_HOME}/platform-tools" >> "${GITHUB_PATH}"
|
||||
|
||||
- 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
|
||||
@@ -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: "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"
|
||||
@@ -1,3 +1,6 @@
|
||||
# Generated whitelabel config — rebuilt by build_whitelabel.py
|
||||
lib/generated/
|
||||
|
||||
# ---> Dart
|
||||
# See https://www.dartlang.org/guides/libraries/private-files
|
||||
|
||||
@@ -90,3 +93,8 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Python venv
|
||||
.venv/
|
||||
|
||||
# Whitelabel app theming
|
||||
theme.json
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
### 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).
|
||||
|
||||
@@ -12,6 +12,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 = java.util.Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.kryz_go_flutter"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -37,14 +44,28 @@ android {
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
// Use release signing if key.properties exists, otherwise fall back to debug.
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
|
||||
<application
|
||||
android:label="KRYZ Go!"
|
||||
android:label="KMountain Flower Radio"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
@@ -38,7 +40,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
private const val TAG = "KryzAutoService"
|
||||
private const val MEDIA_ID_ROOT = "kryz_root"
|
||||
private const val MEDIA_ID_LIVE = "kryz_live_stream"
|
||||
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
|
||||
private const val STREAM_URL = "https://libretime.westerntechnologies.duckdns.org/main"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
@@ -51,6 +53,14 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
private var pendingStopRequest: Boolean = false
|
||||
private var reconnectScheduled: Boolean = false
|
||||
|
||||
/**
|
||||
* Album-art bitmap loaded from the drawable resource.
|
||||
* Embedded directly into MediaMetadata so Android Auto cannot serve a stale URI cache.
|
||||
*/
|
||||
private val albumArtBitmap: Bitmap by lazy {
|
||||
BitmapFactory.decodeResource(resources, R.drawable.kryz_auto_art)
|
||||
}
|
||||
|
||||
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
||||
applyPlaybackStateFromFlutter(state)
|
||||
@@ -117,6 +127,20 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
dispatchPlayCommand()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an android.resource Uri for the Auto album-art drawable.
|
||||
* Uses R.drawable.kryz_auto_art so the resource shrinker sees the reference
|
||||
* and keeps the image in release APKs.
|
||||
*/
|
||||
private fun getAutoArtUri(): Uri {
|
||||
return Uri.Builder()
|
||||
.scheme("android.resource")
|
||||
.authority(packageName)
|
||||
.appendPath(resources.getResourceTypeName(R.drawable.kryz_auto_art))
|
||||
.appendPath(resources.getResourceEntryName(R.drawable.kryz_auto_art))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "onCreate()")
|
||||
@@ -163,11 +187,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
if (parentId == MEDIA_ID_ROOT) {
|
||||
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||
val iconUri = getAutoArtUri()
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(MEDIA_ID_LIVE)
|
||||
.setTitle("KRYZ Live Stream")
|
||||
.setSubtitle("KRYZ Radio")
|
||||
.setTitle("KMTN Live Stream")
|
||||
.setSubtitle("KMountain Radio")
|
||||
.setIconUri(iconUri)
|
||||
.setMediaUri(Uri.parse(STREAM_URL))
|
||||
.build()
|
||||
@@ -296,10 +320,10 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"KRYZ Auto Playback",
|
||||
"KMountain Flower Radio Auto Playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "KRYZ Radio Android Auto playback"
|
||||
description = "KMountain Flower Radio Android Auto playback"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -314,7 +338,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
else -> "Stopped"
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("KRYZ Radio")
|
||||
.setContentTitle("KMountain Radio")
|
||||
.setContentText(statusText)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(isPlaying || isBuffering)
|
||||
@@ -330,16 +354,16 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
) {
|
||||
if (!::mediaSession.isInitialized) return
|
||||
|
||||
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
|
||||
val iconUri = getAutoArtUri()
|
||||
mediaSession.setMetadata(
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KMTN Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KMountain Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
@@ -347,8 +371,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
||||
if (metadata == null) {
|
||||
applyNowPlayingMetadata(
|
||||
title = "KRYZ Live Stream",
|
||||
subtitle = "KRYZ Radio",
|
||||
title = "KMTN Live Stream",
|
||||
subtitle = "KMountain Radio",
|
||||
album = "Live",
|
||||
mediaUri = STREAM_URL,
|
||||
)
|
||||
@@ -428,15 +452,14 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
||||
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||
return MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KMTN Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KMountain Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
@@ -5,7 +5,7 @@
|
||||
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
|
||||
-->
|
||||
<resources>
|
||||
<color name="kryz_primary">#E36A18</color>
|
||||
<color name="kryz_primary_dark">#B84E08</color>
|
||||
<color name="kryz_accent">#1663C7</color>
|
||||
<color name="kryz_primary">#1a3a5c</color>
|
||||
<color name="kryz_primary_dark">#0e2440</color>
|
||||
<color name="kryz_accent">#e87a2e</color>
|
||||
</resources>
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 422 KiB |
@@ -1,171 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1000"
|
||||
height="1000"
|
||||
viewBox="0 0 1000 1000"
|
||||
version="1.1"
|
||||
id="svg7"
|
||||
sodipodi:docname="icon_2.svg"
|
||||
inkscape:version="1.4.3 (fcd0343856, 2026-01-01)"
|
||||
inkscape:export-filename="app_icon.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs7" />
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.68550502"
|
||||
inkscape:cx="485.04386"
|
||||
inkscape:cy="511.30187"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="962"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg7" />
|
||||
<!-- Background -->
|
||||
<rect
|
||||
x="-2"
|
||||
y="-2"
|
||||
width="1002"
|
||||
height="1002"
|
||||
fill="#0b2e59"
|
||||
id="rect1"
|
||||
style="stroke-width:1.77131" />
|
||||
<!-- Accent bands -->
|
||||
<rect
|
||||
x="19.82901"
|
||||
y="426.9852"
|
||||
width="960.34198"
|
||||
height="79.542099"
|
||||
fill="#f58220"
|
||||
opacity="0.9"
|
||||
id="rect2"
|
||||
style="stroke-width:1.54503" />
|
||||
<rect
|
||||
x="19.82901"
|
||||
y="506.52728"
|
||||
width="960.34198"
|
||||
height="39.771049"
|
||||
fill="#ffb347"
|
||||
opacity="0.9"
|
||||
id="rect3"
|
||||
style="stroke-width:1.54503" />
|
||||
<!-- Subtle inner border -->
|
||||
<rect
|
||||
x="21.657974"
|
||||
y="21.657974"
|
||||
width="956.68402"
|
||||
height="956.68408"
|
||||
fill="none"
|
||||
stroke="#f58220"
|
||||
stroke-width="7.31594"
|
||||
id="rect4" />
|
||||
<!-- Letters group -->
|
||||
<g
|
||||
font-family="system-ui, '-apple-system', BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-weight="800"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
id="g7"
|
||||
transform="matrix(1.4298147,0,0,1.4298147,-109.59816,77.582873)">
|
||||
<!-- K -->
|
||||
<text
|
||||
x="150"
|
||||
y="190"
|
||||
font-size="160px"
|
||||
fill="#f58220"
|
||||
id="text4">K</text>
|
||||
<!-- R -->
|
||||
<text
|
||||
x="320"
|
||||
y="190"
|
||||
font-size="160px"
|
||||
fill="#ffffff"
|
||||
id="text5">R</text>
|
||||
<!-- Y -->
|
||||
<text
|
||||
x="490"
|
||||
y="190"
|
||||
font-size="160px"
|
||||
fill="#f58220"
|
||||
id="text6">Y</text>
|
||||
<!-- G in a blue block -->
|
||||
<rect
|
||||
x="580"
|
||||
y="80"
|
||||
width="170"
|
||||
height="220"
|
||||
rx="18"
|
||||
ry="18"
|
||||
fill="#123b73"
|
||||
id="rect6" />
|
||||
<text
|
||||
x="665"
|
||||
y="190"
|
||||
font-size="160px"
|
||||
fill="#ffb347"
|
||||
id="text7">Z</text>
|
||||
</g>
|
||||
<!-- Small underline accent under the word -->
|
||||
<rect
|
||||
x="19.82901"
|
||||
y="415.05389"
|
||||
width="960.34204"
|
||||
height="11.931314"
|
||||
fill="#ffb347"
|
||||
opacity="0.85"
|
||||
id="rect7"
|
||||
style="stroke-width:2.08331" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-weight:bold;font-size:100.118px;line-height:0;font-family:'Noto Sans Mono CJK KR';-inkscape-font-specification:'Noto Sans Mono CJK KR Bold';letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:#000000;stroke-width:1.65284"
|
||||
x="22.209259"
|
||||
y="501.82516"
|
||||
id="text8"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan7"
|
||||
x="22.209259"
|
||||
y="501.82516"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:100.118px;font-family:C059;-inkscape-font-specification:'C059, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke:none;stroke-width:1.65284">98.5 LPFM</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:169.76px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:2.80256"
|
||||
x="25.539324"
|
||||
y="669.90143"
|
||||
id="text10"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan10"
|
||||
x="25.539324"
|
||||
y="669.90143"
|
||||
style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="25.539324"
|
||||
y="805.70941"
|
||||
id="tspan11"
|
||||
style="line-height:0.8;stroke-width:2.80256">Community</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="25.539324"
|
||||
y="941.5174"
|
||||
style="line-height:0.8;stroke-width:2.80256"
|
||||
id="tspan13">Radio</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:348.901px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:5.76"
|
||||
x="867.28961"
|
||||
y="762.51642"
|
||||
id="text12"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan12" /></text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Whitelabel build script for KRYZ Go Flutter.
|
||||
|
||||
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 SVG)
|
||||
Patches native platform files (AndroidManifest, Info.plist, automotive_colors.xml, Kotlin).
|
||||
Invokes flutter_launcher_icons and flutter_name_manager.
|
||||
|
||||
Usage:
|
||||
python3 build_whitelabel.py [--theme theme.json] [--no-tools]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def hex_to_rgba(hex_color: str, alpha: float = 1.0) -> str:
|
||||
"""Convert '#RRGGBB' hex to '0xAARRGGBB' Dart Color format."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
assert len(hex_color) == 6, f"Expected 6 hex digits, got {len(hex_color)}"
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
a = int(round(alpha * 255))
|
||||
return f"Color(0x{a:02X}{r:02X}{g:02X}{b:02X})"
|
||||
|
||||
|
||||
def darken(hex_color: str, factor: float) -> str:
|
||||
"""Darken a hex color by multiplying RGB channels by factor (0-1)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
r = int(min(r * factor, 255))
|
||||
g = int(min(g * factor, 255))
|
||||
b = int(min(b * factor, 255))
|
||||
return f"Color(0xFF{r:02X}{g:02X}{b:02X})"
|
||||
|
||||
|
||||
def dart_escape(value: str) -> str:
|
||||
"""Escape a string for use in a Dart const string literal."""
|
||||
return value.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||
|
||||
|
||||
def read_theme(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_app_config(theme: dict) -> str:
|
||||
station = theme["station"]
|
||||
streams = theme["streams"]
|
||||
|
||||
name_primary = station.get("name_primary", "")
|
||||
name_secondary = station.get("name_secondary", "")
|
||||
callsign = station.get("callsign", "")
|
||||
tagline = station.get("tagline", "")
|
||||
frequency = station.get("frequency", "")
|
||||
stream_url = streams.get("url", "")
|
||||
metadata_url = streams.get("metadata_url")
|
||||
|
||||
app_name = f"{name_primary} {name_secondary}".strip()
|
||||
default_title = f"{callsign or name_primary} Live Stream"
|
||||
default_artist = f"{name_primary} Radio"
|
||||
|
||||
metadata_url_line = ""
|
||||
if metadata_url:
|
||||
metadata_url_line = f" static const String metadataUrl = '{dart_escape(metadata_url)}';\n"
|
||||
else:
|
||||
metadata_url_line = " static const String? metadataUrl = null;\n"
|
||||
|
||||
return f'''// GENERATED FILE — DO NOT EDIT.
|
||||
// Run `python3 build_whitelabel.py` to regenerate.
|
||||
|
||||
class AppConfig {{
|
||||
static const String callsign = '{dart_escape(callsign)}';
|
||||
static const String appName = '{dart_escape(app_name)}';
|
||||
static const String tagline = '{dart_escape(tagline)}';
|
||||
static const String frequency = '{dart_escape(frequency)}';
|
||||
static const String streamUrl = '{dart_escape(stream_url)}';
|
||||
{metadata_url_line.rstrip()}
|
||||
static const String defaultTitle = '{dart_escape(default_title)}';
|
||||
static const String defaultArtist = '{dart_escape(default_artist)}';
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme colors generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_theme_colors(theme: dict) -> str:
|
||||
c = theme["colors"]
|
||||
|
||||
# Light mode — mapped to match companion website color usage
|
||||
light_props = [
|
||||
("headingColor", hex_to_rgba(c["primary"])),
|
||||
("accentColor", hex_to_rgba(c["accent"])),
|
||||
("scaffoldGradientStart", hex_to_rgba(c["background"])),
|
||||
("scaffoldGradientEnd", hex_to_rgba(c["light"])),
|
||||
("shellSurface", hex_to_rgba(c["white"], 0.97)),
|
||||
("shellBorder", hex_to_rgba(c["medium"], 0.10)),
|
||||
("mutedText", hex_to_rgba(c["medium"])),
|
||||
("textOnDark", hex_to_rgba(c["white"])),
|
||||
("textOnDarkSecondary", hex_to_rgba(c["light"])),
|
||||
("controlsGradientStart", hex_to_rgba(c["primary"])),
|
||||
("controlsGradientEnd", hex_to_rgba(c["primary_light"])),
|
||||
("controlsShadow", hex_to_rgba(c["black"], 0.14)),
|
||||
("bodyText", hex_to_rgba(c["text"])),
|
||||
("cardSurface", hex_to_rgba(c["white"], 0.08)),
|
||||
("cardBorder", hex_to_rgba(c["white"], 0.12)),
|
||||
("successColor", hex_to_rgba(c["success"])),
|
||||
("dangerColor", hex_to_rgba(c["danger"])),
|
||||
]
|
||||
|
||||
# Dark mode — mapped to match companion website color usage
|
||||
dark_props = [
|
||||
("headingColor", hex_to_rgba(c["primary_light"])),
|
||||
("accentColor", hex_to_rgba(c["accent_light"])),
|
||||
("scaffoldGradientStart", hex_to_rgba(c["black"])),
|
||||
("scaffoldGradientEnd", darken(c["primary_dark"], 0.30)),
|
||||
("shellSurface", hex_to_rgba(c["primary_dark"], 0.85)),
|
||||
("shellBorder", hex_to_rgba(c["medium"], 0.20)),
|
||||
("mutedText", hex_to_rgba(c["medium"])),
|
||||
("textOnDark", hex_to_rgba(c["white"])),
|
||||
("textOnDarkSecondary", hex_to_rgba(c["light"])),
|
||||
("controlsGradientStart", hex_to_rgba(c["primary_dark"])),
|
||||
("controlsGradientEnd", hex_to_rgba(c["primary"])),
|
||||
("controlsShadow", hex_to_rgba(c["black"], 0.27)),
|
||||
("bodyText", hex_to_rgba(c["light"])),
|
||||
("cardSurface", hex_to_rgba(c["white"], 0.08)),
|
||||
("cardBorder", hex_to_rgba(c["white"], 0.12)),
|
||||
("successColor", hex_to_rgba(c["success"])),
|
||||
("dangerColor", hex_to_rgba(c["danger"])),
|
||||
]
|
||||
|
||||
def props_block(props, indent: str = " ") -> str:
|
||||
lines = []
|
||||
for name, color_expr in props:
|
||||
lines.append(f"{indent}{name}: {color_expr},")
|
||||
return "\n".join(lines)
|
||||
|
||||
return f'''// GENERATED FILE — DO NOT EDIT.
|
||||
// Run `python3 build_whitelabel.py` to regenerate.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_theme_business_object.dart';
|
||||
|
||||
class GeneratedThemeColors {{
|
||||
static const AppThemeColors lightColors = AppThemeColors(
|
||||
{props_block(light_props)}
|
||||
);
|
||||
|
||||
static const AppThemeColors darkColors = AppThemeColors(
|
||||
{props_block(dark_props)}
|
||||
);
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icon generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_icon(theme: dict, dry_run: bool = False) -> None:
|
||||
"""Decode embedded logo (JPEG, PNG, or SVG) and write app_icon.png."""
|
||||
embedded = theme.get("images_embedded", {})
|
||||
logo = embedded.get("logo", {})
|
||||
data_b64 = logo.get("data", "")
|
||||
content_type = logo.get("content_type", "")
|
||||
if not data_b64:
|
||||
print(" [icon] No embedded logo data — skipping icon generation")
|
||||
return
|
||||
|
||||
raw_bytes = base64.b64decode(data_b64)
|
||||
icon_path = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.png")
|
||||
|
||||
if dry_run:
|
||||
print(f" [icon] Would generate PNG from embedded {content_type or 'image'} at assets/icon/app_icon.png")
|
||||
return
|
||||
|
||||
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
|
||||
if content_type in ("image/jpeg", "image/png"):
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
|
||||
img = img.resize((1024, 1024), Image.LANCZOS)
|
||||
img.save(icon_path, "PNG")
|
||||
print(f" [icon] Converted {content_type} → PNG (Pillow, 1024×1024)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [icon] Pillow conversion failed: {e} — writing raw bytes")
|
||||
|
||||
# Fallback: write raw bytes directly (flutter_launcher_icons accepts JPEG)
|
||||
if content_type == "image/jpeg":
|
||||
fallback = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.jpg")
|
||||
else:
|
||||
fallback = icon_path
|
||||
with open(fallback, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
print(f" [icon] Wrote embedded {content_type} (no Pillow — using raw bytes)")
|
||||
# If we wrote a JPEG, rename the pubspec image_path is still .png,
|
||||
# so try to at least rename it
|
||||
if fallback != icon_path:
|
||||
os.replace(fallback, icon_path)
|
||||
return
|
||||
|
||||
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
|
||||
try:
|
||||
import cairosvg
|
||||
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
|
||||
print(" [icon] Rendered SVG → PNG (cairosvg)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [icon] cairosvg failed: {e} — trying rsvg-convert")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
|
||||
tmp.write(raw_bytes)
|
||||
tmp_path = tmp.name
|
||||
subprocess.run(
|
||||
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
os.unlink(tmp_path)
|
||||
print(" [icon] Rendered SVG → PNG (rsvg-convert)")
|
||||
except Exception as e:
|
||||
print(f" [icon] WARNING: Could not render icon: {e}")
|
||||
print(" [icon] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
|
||||
|
||||
|
||||
def generate_android_auto_art(theme: dict, dry_run: bool = False) -> None:
|
||||
"""Decode embedded hero_icon (JPEG, PNG, or SVG) and write kryz_auto_art.png."""
|
||||
embedded = theme["images_embedded"]
|
||||
hero_data = embedded["hero_icon"]["data"]
|
||||
hero_content_type = embedded["hero_icon"].get("content_type", "")
|
||||
print(" [android-auto-art] Using 'hero_icon' from images_embedded")
|
||||
|
||||
raw_bytes = base64.b64decode(hero_data)
|
||||
icon_path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.png")
|
||||
|
||||
if dry_run:
|
||||
print(f" [android-auto-art] Would generate PNG from embedded {hero_content_type or 'image'} at android/app/src/main/res/drawable-nodpi/kryz_auto_art.png")
|
||||
return
|
||||
|
||||
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
|
||||
if hero_content_type in ("image/jpeg", "image/png"):
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
|
||||
img = img.resize((1024, 1024), Image.LANCZOS)
|
||||
img.save(icon_path, "PNG")
|
||||
print(f" [android-auto-art] Converted {hero_content_type} → PNG (Pillow, 1024×1024)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] Pillow conversion failed: {e} — writing raw bytes")
|
||||
|
||||
# Fallback: write raw bytes directly
|
||||
if hero_content_type == "image/jpeg":
|
||||
fallback = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.jpg")
|
||||
else:
|
||||
fallback = icon_path
|
||||
with open(fallback, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
print(f" [android-auto-art] Wrote embedded {hero_content_type} (no Pillow — using raw bytes)")
|
||||
if fallback != icon_path:
|
||||
os.replace(fallback, icon_path)
|
||||
return
|
||||
|
||||
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
|
||||
try:
|
||||
import cairosvg
|
||||
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
|
||||
print(" [android-auto-art] Rendered SVG → PNG (cairosvg)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] cairosvg failed: {e} — trying rsvg-convert")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
|
||||
tmp.write(raw_bytes)
|
||||
tmp_path = tmp.name
|
||||
subprocess.run(
|
||||
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
os.unlink(tmp_path)
|
||||
print(" [android-auto-art] Rendered SVG → PNG (rsvg-convert)")
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] WARNING: Could not render icon: {e}")
|
||||
print(" [android-auto-art] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native file patching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_android_manifest(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "AndroidManifest.xml")
|
||||
if not os.path.exists(path):
|
||||
print(" [android] AndroidManifest.xml not found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
new_content = re.sub(
|
||||
r'android:label="[^"]*"',
|
||||
f'android:label="{app_name}"',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(f" [android] Would patch AndroidManifest.xml label → '{app_name}'")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
print(f" [android] Patched AndroidManifest.xml label → '{app_name}'")
|
||||
|
||||
|
||||
def patch_info_plist(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "ios", "Runner", "Info.plist")
|
||||
if not os.path.exists(path):
|
||||
print(" [ios] Info.plist not found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace CFBundleDisplayName value
|
||||
content = re.sub(
|
||||
r'(<key>CFBundleDisplayName</key>\s*<string>)[^<]*(</string>)',
|
||||
rf'\1{app_name}\2',
|
||||
content,
|
||||
)
|
||||
# Replace CFBundleName value
|
||||
content = re.sub(
|
||||
r'(<key>CFBundleName</key>\s*<string>)[^<]*(</string>)',
|
||||
rf'\1{app_name}\2',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(f" [ios] Would patch Info.plist CFBundleDisplayName/CFBundleName → '{app_name}'")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" [ios] Patched Info.plist → '{app_name}'")
|
||||
|
||||
|
||||
def patch_automotive_colors(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "values", "automotive_colors.xml")
|
||||
if not os.path.exists(path):
|
||||
print(" [android-auto] automotive_colors.xml not found — skipping")
|
||||
return
|
||||
|
||||
c = theme["colors"]
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace hex values in color tags
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_primary">)#[^<]*</color>',
|
||||
rf'\1{c["primary"]}</color>',
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_primary_dark">)#[^<]*</color>',
|
||||
rf'\1{c["primary_dark"]}</color>',
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_accent">)#[^<]*</color>',
|
||||
rf'\1{c["accent"]}</color>',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(" [android-auto] Would patch automotive_colors.xml")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(" [android-auto] Patched automotive_colors.xml")
|
||||
|
||||
|
||||
def patch_kotlin_service(theme: dict, dry_run: bool = False) -> None:
|
||||
kts = list(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter", f)
|
||||
for f in os.listdir(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
|
||||
)
|
||||
) if os.path.isdir(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
|
||||
) else []
|
||||
|
||||
if not kts:
|
||||
print(" [kotlin] No Kotlin files found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
streams = theme["streams"]
|
||||
name_primary = station.get("name_primary", "KRYZ")
|
||||
callsign = station.get("callsign", name_primary)
|
||||
app_name = f"{name_primary} {station.get('name_secondary','')}".strip()
|
||||
default_title = f"{callsign} Live Stream"
|
||||
default_artist = f"{name_primary} Radio"
|
||||
stream_url = streams.get("url", "")
|
||||
|
||||
replacements = [
|
||||
# STREAM_URL constant
|
||||
(r'(private const val STREAM_URL = ")[^"]*(")', rf'\g<1>{stream_url}\2'),
|
||||
# "KRYZ Live Stream" → default title
|
||||
(r'"KRYZ Live Stream"', f'"{default_title}"'),
|
||||
# "KRYZ Radio" → default artist
|
||||
(r'"KRYZ Radio"', f'"{default_artist}"'),
|
||||
# "KRYZ Auto Playback" → app name + " Auto Playback"
|
||||
(r'"KRYZ Auto Playback"', f'"{app_name} Auto Playback"'),
|
||||
# "KRYZ Radio Android Auto playback" → descriptive text
|
||||
(r'"KRYZ Radio Android Auto playback"', f'"{app_name} Android Auto playback"'),
|
||||
]
|
||||
|
||||
for kfile in kts:
|
||||
if not kfile.endswith(".kt"):
|
||||
continue
|
||||
with open(kfile, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
original = content
|
||||
for pattern, replacement in replacements:
|
||||
content = re.sub(pattern, replacement, content)
|
||||
|
||||
if content == original:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" [kotlin] Would patch {os.path.basename(kfile)}")
|
||||
return
|
||||
|
||||
with open(kfile, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" [kotlin] Patched {os.path.basename(kfile)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Whitelabel build script")
|
||||
parser.add_argument("--theme", default=os.path.join(PROJECT_ROOT, "theme.json"), help="Path to theme.json")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without writing files")
|
||||
parser.add_argument("--no-tools", action="store_true", help="Skip invoking flutter_launcher_icons and flutter_name_manager")
|
||||
args = parser.parse_args()
|
||||
|
||||
theme_path = args.theme
|
||||
if not os.path.exists(theme_path):
|
||||
print(f"Error: theme.json not found at {theme_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {theme_path} ...")
|
||||
theme = read_theme(theme_path)
|
||||
|
||||
station = theme.get("station", {})
|
||||
if not station.get("name_primary"):
|
||||
print("Error: theme.json is missing station.name_primary")
|
||||
sys.exit(1)
|
||||
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
print(f"Whitelabel: {app_name}")
|
||||
print()
|
||||
|
||||
# Generate Dart files
|
||||
gen_dir = os.path.join(PROJECT_ROOT, "lib", "generated")
|
||||
os.makedirs(gen_dir, exist_ok=True)
|
||||
|
||||
app_config_path = os.path.join(gen_dir, "app_config.dart")
|
||||
theme_colors_path = os.path.join(gen_dir, "theme_colors.dart")
|
||||
|
||||
if args.dry_run:
|
||||
print(" [gen] Would generate lib/generated/app_config.dart")
|
||||
print(" [gen] Would generate lib/generated/theme_colors.dart")
|
||||
else:
|
||||
with open(app_config_path, "w", encoding="utf-8") as f:
|
||||
f.write(generate_app_config(theme))
|
||||
print(" [gen] Generated lib/generated/app_config.dart")
|
||||
|
||||
with open(theme_colors_path, "w", encoding="utf-8") as f:
|
||||
f.write(generate_theme_colors(theme))
|
||||
print(" [gen] Generated lib/generated/theme_colors.dart")
|
||||
|
||||
# Generate icon
|
||||
print()
|
||||
generate_icon(theme, dry_run=args.dry_run)
|
||||
|
||||
# Generate Android Auto album art
|
||||
print()
|
||||
generate_android_auto_art(theme, dry_run=args.dry_run)
|
||||
|
||||
# Patch native files
|
||||
print()
|
||||
patch_android_manifest(theme, dry_run=args.dry_run)
|
||||
patch_info_plist(theme, dry_run=args.dry_run)
|
||||
patch_automotive_colors(theme, dry_run=args.dry_run)
|
||||
patch_kotlin_service(theme, dry_run=args.dry_run)
|
||||
|
||||
# Invoke downstream tools
|
||||
if not args.dry_run and not args.no_tools:
|
||||
print()
|
||||
print("Running flutter_launcher_icons ...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["dart", "run", "flutter_launcher_icons"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
print(" flutter_launcher_icons succeeded")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: flutter_launcher_icons failed: {e}")
|
||||
except FileNotFoundError:
|
||||
print(" WARNING: 'dart' not found — run `flutter pub get` first")
|
||||
|
||||
print()
|
||||
print("Running flutter_name_manager ...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["dart", "run", "flutter_name_manager", "--app-name", app_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
print(" flutter_name_manager succeeded")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: flutter_name_manager failed: {e}")
|
||||
except FileNotFoundError:
|
||||
print(" WARNING: 'dart' not found — run `flutter pub get` first")
|
||||
|
||||
print()
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 936 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
@@ -14,6 +14,7 @@ import UIKit
|
||||
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
||||
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
|
||||
*/
|
||||
@available(iOS 14.0, *)
|
||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
|
||||
private var carPlayInterfaceController: CPInterfaceController?
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>KRYZ Go!</string>
|
||||
<string>KMountain Flower Radio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>KRYZ Go!</string>
|
||||
<string>KMountain Flower Radio</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -1,69 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'generated/theme_colors.dart';
|
||||
|
||||
class AppThemeColors {
|
||||
const AppThemeColors({
|
||||
required this.primarySeed,
|
||||
required this.secondarySeed,
|
||||
required this.headingColor,
|
||||
required this.accentColor,
|
||||
required this.scaffoldGradientStart,
|
||||
required this.scaffoldGradientEnd,
|
||||
required this.shellSurface,
|
||||
required this.shellBorder,
|
||||
required this.accent,
|
||||
required this.webViewBorder,
|
||||
required this.webViewBackground,
|
||||
required this.mutedText,
|
||||
required this.textOnDark,
|
||||
required this.textOnDarkSecondary,
|
||||
required this.controlsGradientStart,
|
||||
required this.controlsGradientEnd,
|
||||
required this.controlsShadow,
|
||||
required this.statusText,
|
||||
required this.bodyText,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
required this.successColor,
|
||||
required this.dangerColor,
|
||||
});
|
||||
|
||||
final Color primarySeed;
|
||||
final Color secondarySeed;
|
||||
/// 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;
|
||||
final Color accent;
|
||||
final Color webViewBorder;
|
||||
final Color webViewBackground;
|
||||
|
||||
/// 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;
|
||||
final Color statusText;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
class AppThemeBusinessObject {
|
||||
static const AppThemeColors lightColors = AppThemeColors(
|
||||
primarySeed: Color(0xFFE36A18),
|
||||
secondarySeed: Color(0xFF1663C7),
|
||||
scaffoldGradientStart: Color(0xFFFFF3E3),
|
||||
scaffoldGradientEnd: Color(0xFFE6F2FF),
|
||||
shellSurface: Color(0xF7FFFFFF),
|
||||
shellBorder: Color(0x1A1A4D86),
|
||||
accent: Color(0xFF0F5DB6),
|
||||
webViewBorder: Color(0x261A4D86),
|
||||
webViewBackground: Color(0xFFFFFFFF),
|
||||
controlsGradientStart: Color(0xFFFFB465),
|
||||
controlsGradientEnd: Color(0xFF69ADFF),
|
||||
controlsShadow: Color(0x24114B7A),
|
||||
statusText: Color(0xFF13243D),
|
||||
);
|
||||
|
||||
static const AppThemeColors darkColors = AppThemeColors(
|
||||
primarySeed: Color(0xFFFF9D59),
|
||||
secondarySeed: Color(0xFF76B8FF),
|
||||
scaffoldGradientStart: Color(0xFF0F1C30),
|
||||
scaffoldGradientEnd: Color(0xFF31190C),
|
||||
shellSurface: Color(0xD9162438),
|
||||
shellBorder: Color(0x3366AAFF),
|
||||
accent: Color(0xFFFFBC7E),
|
||||
webViewBorder: Color(0x3378B7FF),
|
||||
webViewBackground: Color(0xFF0C1420),
|
||||
controlsGradientStart: Color(0xFF275792),
|
||||
controlsGradientEnd: Color(0xFF9A4F1F),
|
||||
controlsShadow: Color(0x44000000),
|
||||
statusText: Color(0xFFF4F7FF),
|
||||
);
|
||||
static const AppThemeColors lightColors = GeneratedThemeColors.lightColors;
|
||||
static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
|
||||
|
||||
static bool isDarkMode(BuildContext context) {
|
||||
return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
|
||||
@@ -74,16 +88,16 @@ class AppThemeBusinessObject {
|
||||
}
|
||||
|
||||
static ThemeData lightTheme() {
|
||||
final ColorScheme scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: lightColors.primarySeed,
|
||||
final ColorScheme scheme = ColorScheme.light(
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
primary: lightColors.primarySeed,
|
||||
secondary: lightColors.secondarySeed,
|
||||
tertiary: const Color(0xFF2C85E1),
|
||||
surface: const Color(0xFFFFFBF7),
|
||||
onSurface: lightColors.statusText,
|
||||
primary: lightColors.headingColor,
|
||||
onPrimary: lightColors.textOnDark,
|
||||
secondary: lightColors.accentColor,
|
||||
onSecondary: lightColors.textOnDark,
|
||||
surface: lightColors.shellSurface,
|
||||
onSurface: lightColors.bodyText,
|
||||
error: lightColors.dangerColor,
|
||||
onError: lightColors.textOnDark,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
@@ -104,16 +118,16 @@ class AppThemeBusinessObject {
|
||||
}
|
||||
|
||||
static ThemeData darkTheme() {
|
||||
final ColorScheme scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: darkColors.primarySeed,
|
||||
final ColorScheme scheme = ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(
|
||||
primary: darkColors.primarySeed,
|
||||
secondary: darkColors.secondarySeed,
|
||||
tertiary: const Color(0xFF4E97E8),
|
||||
surface: const Color(0xFF111A27),
|
||||
onSurface: darkColors.statusText,
|
||||
primary: darkColors.headingColor,
|
||||
onPrimary: darkColors.textOnDark,
|
||||
secondary: darkColors.accentColor,
|
||||
onSecondary: darkColors.textOnDark,
|
||||
surface: darkColors.shellSurface,
|
||||
onSurface: darkColors.bodyText,
|
||||
error: darkColors.dangerColor,
|
||||
onError: darkColors.textOnDark,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:audio_session/audio_session.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
|
||||
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
@@ -16,21 +17,18 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
);
|
||||
}
|
||||
|
||||
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||
static const String streamUrl = AppConfig.streamUrl;
|
||||
static const String mediaId = 'kryz-live-stream';
|
||||
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info';
|
||||
static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? '';
|
||||
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||
|
||||
static const MediaItem _defaultMediaItem = MediaItem(
|
||||
id: mediaId,
|
||||
title: 'KRYZ Live Stream',
|
||||
artist: 'KRYZ Radio',
|
||||
title: AppConfig.defaultTitle,
|
||||
artist: AppConfig.defaultArtist,
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
},
|
||||
extras: <String, dynamic>{'isLive': true, 'seekable': false},
|
||||
);
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
@@ -52,7 +50,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
MediaItem(
|
||||
id: mediaId,
|
||||
title: current.displayTitle,
|
||||
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle,
|
||||
artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle,
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
@@ -82,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;
|
||||
}
|
||||
@@ -158,7 +153,8 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
}
|
||||
|
||||
void _onPlayerStateChanged(PlayerState state) {
|
||||
final AudioProcessingState processingState = switch (state.processingState) {
|
||||
final AudioProcessingState processingState =
|
||||
switch (state.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||
@@ -166,17 +162,26 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
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: <MediaControl>[
|
||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
||||
if (isPlaying || isBuffering)
|
||||
MediaControl.stop
|
||||
else
|
||||
MediaControl.play,
|
||||
],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
||||
processingState: isBuffering
|
||||
? AudioProcessingState.buffering
|
||||
: processingState,
|
||||
playing: isPlaying,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,17 +4,35 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
|
||||
class LiveInfoBusinessObject extends ChangeNotifier {
|
||||
LiveInfoBusinessObject({
|
||||
this.endpoint = const String.fromEnvironment(
|
||||
'KRYZ_LIVE_INFO_URL',
|
||||
defaultValue: 'http://kryz.airtime.pro/api/live-info',
|
||||
),
|
||||
String? endpoint,
|
||||
int refreshIntervalSeconds = 20,
|
||||
this.requestTimeout = const Duration(seconds: 8),
|
||||
}) : _refreshIntervalSeconds = refreshIntervalSeconds;
|
||||
}) : _endpointOverride = endpoint,
|
||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
||||
|
||||
/// Explicit endpoint passed at construction, if any.
|
||||
final String? _endpointOverride;
|
||||
|
||||
/// The effective polling endpoint: construction override > env var > AppConfig.
|
||||
String get endpoint {
|
||||
final override = _endpointOverride;
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
const envUrl = String.fromEnvironment(
|
||||
'KRYZ_LIVE_INFO_URL',
|
||||
defaultValue: '',
|
||||
);
|
||||
if (envUrl.isNotEmpty) {
|
||||
return envUrl;
|
||||
}
|
||||
return AppConfig.metadataUrl ?? '';
|
||||
}
|
||||
|
||||
final String endpoint;
|
||||
final Duration requestTimeout;
|
||||
|
||||
Timer? _pollTimer;
|
||||
@@ -34,6 +52,10 @@ class LiveInfoBusinessObject extends ChangeNotifier {
|
||||
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
|
||||
|
||||
Future<void> start() async {
|
||||
// Skip polling if no endpoint is configured
|
||||
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
_restartTimer();
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class LiveInfoPanel extends StatelessWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
color: themeColors.statusText,
|
||||
color: themeColors.textOnDarkSecondary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -67,7 +67,7 @@ class LiveInfoPanel extends StatelessWidget {
|
||||
'Live Studio Feed',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: themeColors.statusText,
|
||||
color: themeColors.textOnDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -94,7 +94,9 @@ class LiveInfoPanel extends StatelessWidget {
|
||||
child: TrackCard(
|
||||
label: 'Now Playing',
|
||||
track: current,
|
||||
titleColor: themeColors.statusText,
|
||||
titleColor: themeColors.textOnDark,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -102,7 +104,9 @@ class LiveInfoPanel extends StatelessWidget {
|
||||
child: TrackCard(
|
||||
label: 'Coming Next',
|
||||
track: next,
|
||||
titleColor: themeColors.statusText,
|
||||
titleColor: themeColors.textOnDark,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -113,14 +117,18 @@ class LiveInfoPanel extends StatelessWidget {
|
||||
label: 'On Air Show',
|
||||
show: currentShow,
|
||||
sourceTimezone: snapshot?.timezone,
|
||||
titleColor: themeColors.statusText,
|
||||
titleColor: themeColors.textOnDarkSecondary,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ShowCard(
|
||||
label: 'Up Next Show',
|
||||
show: nextShow,
|
||||
sourceTimezone: snapshot?.timezone,
|
||||
titleColor: themeColors.statusText,
|
||||
titleColor: themeColors.textOnDarkSecondary,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
import 'generated/app_config.dart';
|
||||
import 'app_theme_business_object.dart';
|
||||
import 'casting_business_object.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
@@ -53,7 +54,7 @@ class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'KRYZ Go!',
|
||||
title: AppConfig.appName,
|
||||
theme: AppThemeBusinessObject.lightTheme(),
|
||||
darkTheme: AppThemeBusinessObject.darkTheme(),
|
||||
themeMode: ThemeMode.system,
|
||||
@@ -394,12 +395,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'KRYZ Go!',
|
||||
AppConfig.appName,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.4,
|
||||
color: themeColors.statusText,
|
||||
color: themeColors.headingColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -457,8 +458,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
label: const Text('Stop'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed:
|
||||
_casting.status == CastingStatus.connecting
|
||||
onPressed: _casting.status == CastingStatus.connecting
|
||||
? null
|
||||
: _toggleCasting,
|
||||
icon: Icon(_castingIcon()),
|
||||
@@ -468,7 +468,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
_playbackStatusLabel(),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: themeColors.statusText,
|
||||
color: themeColors.textOnDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,12 +10,16 @@ class ShowCard extends StatelessWidget {
|
||||
required this.show,
|
||||
required this.sourceTimezone,
|
||||
required this.titleColor,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final LiveInfoShowInfo? show;
|
||||
final String? sourceTimezone;
|
||||
final Color titleColor;
|
||||
final Color cardSurface;
|
||||
final Color cardBorder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -23,8 +27,8 @@ class ShowCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
color: cardSurface,
|
||||
border: Border.all(color: cardBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -35,18 +39,18 @@ class ShowCard extends StatelessWidget {
|
||||
'$label: ${show?.name ?? 'No scheduled show'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formatStartingTime(
|
||||
show?.starts,
|
||||
sourceTimezone: sourceTimezone,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
formatStartingTime(show?.starts, sourceTimezone: sourceTimezone),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: titleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
import 'kryz_audio_handler.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
|
||||
@@ -34,18 +35,22 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_audioHandlerInitFuture ??= AudioService.init(
|
||||
_audioHandlerInitFuture ??=
|
||||
AudioService.init(
|
||||
builder: () => KryzAudioHandler(),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
||||
androidNotificationChannelName: 'KRYZ Audio Playback',
|
||||
androidNotificationChannelName:
|
||||
'${AppConfig.appName} Audio Playback',
|
||||
androidNotificationOngoing: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
),
|
||||
).then((handler) {
|
||||
)
|
||||
.then((handler) {
|
||||
_audioHandler = handler;
|
||||
return handler;
|
||||
}).catchError((error) {
|
||||
})
|
||||
.catchError((error) {
|
||||
_audioHandlerInitFuture = null;
|
||||
throw error;
|
||||
});
|
||||
@@ -60,8 +65,8 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
||||
bool _isDisposed = false;
|
||||
bool _isAttached = false;
|
||||
String _currentTitle = 'KRYZ Live Stream';
|
||||
String _currentSubtitle = 'KRYZ Radio';
|
||||
String _currentTitle = AppConfig.defaultTitle;
|
||||
String _currentSubtitle = AppConfig.defaultArtist;
|
||||
|
||||
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
||||
@@ -145,9 +150,12 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
|
||||
final subtitle =
|
||||
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,11 +9,15 @@ class TrackCard extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.track,
|
||||
required this.titleColor,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final LiveInfoTrackInfo? track;
|
||||
final Color titleColor;
|
||||
final Color cardSurface;
|
||||
final Color cardBorder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,9 +28,9 @@ class TrackCard extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
color: cardSurface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
|
||||
border: Border.all(color: cardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -43,23 +47,27 @@ class TrackCard extends StatelessWidget {
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: titleColor),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
window,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||