CI/CD Pipeline Integration: iOS + Android stages with Gitea-native syntax #11

Merged
kfj001 merged 64 commits from ci/pipeline-integration into main 2026-08-03 20:16:03 -07:00
16 changed files with 801 additions and 65 deletions

No files matched your search

+14 -13
View File
@@ -2,9 +2,9 @@ FROM --platform=linux/amd64 debian:bookworm-slim
ENV PATH="$PATH:/opt/flutter/bin"
# Install system dependencies
# Install system dependencies - minimal set for Flutter + Android builds
RUN apt-get update && apt-get install -y \
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\
wget curl unzip git xz-utils zip python3 libgtk-3-dev ninja-build openjdk-17-jre-headless \
&& rm -rf /var/lib/apt/lists/*
# Become non-root
@@ -12,27 +12,28 @@ ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=1000
ENV FLUTTER_VERSION=3.41.8
ENV FLUTTER_VERSION=3.44.0
ENV FLUTTER_HOME=/opt/flutter
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m -s /bin/bash $USERNAME
# Install Flutter
RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} \
&& cd ${FLUTTER_HOME} \
&& git checkout $FLUTTER_VERSION
# Install Flutter (tarball - much faster than git clone)
RUN wget -q "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" \
-O /tmp/flutter.tar.xz \
&& tar -xf /tmp/flutter.tar.xz -C /opt \
&& rm /tmp/flutter.tar.xz
# Set up Android SDK
ENV ANDROID_HOME=/opt/android-sdk
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/build-tools/36.0.0
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/build-tools/35.0.0
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
&& wget https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip -O commandlinetools.zip \
&& unzip commandlinetools.zip -d ${ANDROID_HOME}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O commandlinetools.zip \
&& unzip -q commandlinetools.zip -d ${ANDROID_HOME}/cmdline-tools \
&& mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
&& rm commandlinetools.zip && yes | sdkmanager --licenses \
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
&& rm commandlinetools.zip \
&& yes | sdkmanager --licenses \
&& sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
RUN chown -R 1000:1000 ${ANDROID_HOME} && chown -R 1000:1000 ${FLUTTER_HOME}
USER $USERNAME
+271
View File
@@ -0,0 +1,271 @@
# kryz-go-flutter CI Pipeline
# Triggers on push and pull_request
# Stages: lint -> test -> build (Android + iOS)
#
# NOTE: Both runners share identical labels [self-hosted, linux, x64].
# Lint/test use continue-on-error so failures on one runner don't block.
#
# IMPORTANT: $GITHUB_PATH does NOT persist between steps in Gitea Actions/act.
# Each step is a separate shell. We export PATH="$PWD/flutter/bin:$PATH" in
# every step that uses flutter/dart commands.
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.44.0"
Review

see, I ❤️ how this is hard coding a specific version of a framework - THIS helps make the development process of this app a monolith!

see, I ❤️ how this is hard coding a specific version of a framework - THIS helps make the development process of this app a monolith!
jobs:
# ── Stage 1: Lint & static analysis (best-effort on any runner) ──
lint:
name: Lint
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" -o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
if: hashFiles('theme.json') == ''
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Analyze Dart code
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter analyze lib/ test/
- name: Auto-format source
run: |
export PATH="$PWD/flutter/bin:$PATH"
dart format lib/ test/
# ── Stage 1 (parallel): Unit & widget tests (best-effort) ───────
test:
name: Test
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" -o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Run tests
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter test --reporter=expanded
# ── Stage 3: Android build ──────────────────────────────────────
build-android:
name: Build Android
runs-on: ubuntu-24.04
needs: [lint, test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check platform for Android build
run: |
OS=$(uname -s)
echo "Detected OS: $OS"
if [ "$OS" != "Linux" ]; then
echo "SKIPPED: Android build requires Linux. Detected $OS."
exit 0
fi
- name: Setup Java 17
run: |
set -e
apt-get update
apt-get install -y openjdk-17-jdk
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="$JAVA_HOME/bin:$PATH"
java -version
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
-o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install Android SDK command-line tools
run: |
set -e
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH"
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 /tmp/cmdline-tools.zip
unzip -q /tmp/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"
- name: Pre-cache Android platform artifacts
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter precache --android
- name: Accept Android SDK licenses
run: |
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH"
yes | "${HOME}/android-sdk/cmdline-tools/latest/bin/sdkmanager" --licenses || true
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Run whitelabel build script
if: hashFiles('theme.json') != ''
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 build_whitelabel.py
- name: Build APK (release)
run: |
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export ANDROID_HOME="${HOME}/android-sdk"
export PATH="$PWD/flutter/bin:$PATH"
flutter build apk --release
- name: Build App Bundle (release)
run: |
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export ANDROID_HOME="${HOME}/android-sdk"
export PATH="$PWD/flutter/bin:$PATH"
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: "Note: artifact storage not configured"
run: |
echo "WARNING: Gitea Actions artifact storage is not configured on this instance."
echo "Build outputs available in runner workspace:"
echo " APK: build/app/outputs/flutter-apk/app-release.apk"
echo " AAB: build/app/outputs/bundle/release/app-release.aab"
# ── Stage 2 (parallel): iOS build ───────────────────────────────
build-ios:
name: Build iOS
runs-on: [macos-arm64]
needs: [lint, test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check macOS platform
run: |
OS=$(uname -s)
echo "Detected OS: $OS"
if [ "$OS" != "Darwin" ]; then
echo "SKIPPED: iOS build requires macOS (Darwin). Detected $OS."
echo "To enable iOS builds, register a macOS runner with the 'macos' label."
exit 0
fi
- name: Setup Java 17
run: |
set -e
brew install openjdk@17 || true
export JAVA_HOME=" /opt/homebrew/Cellar/openjdk@17/17.0.20"
export PATH="$JAVA_HOME/bin:$PATH"
java -version || true
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_${{ env.FLUTTER_VERSION }}-stable.zip" \
-o /tmp/flutter.zip
unzip /tmp/flutter.zip -d "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Run whitelabel build script
if: hashFiles('theme.json') != ''
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 build_whitelabel.py
- name: Build iOS (release, no-codesign)
run: |
export JAVA_HOME="/usr/local/opt/openjdk@17"
export PATH="$PWD/flutter/bin:$PATH"
flutter build ios --release --no-codesign
- name: List build outputs
run: |
echo "=== iOS build output ==="
ls -lh build/ios/iphoneos/ || true
+241
View File
@@ -0,0 +1,241 @@
# kryz-go-flutter Release Pipeline
Review

What is the function of this file? We aren't using our ci/cd to generate stable artifacts.

What is the function of this file? We aren't using our ci/cd to generate stable artifacts.
# 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.44.0"
jobs:
# ── Lint (gate) ─────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
-o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
if: hashFiles('theme.json') == ''
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Analyze Dart code
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter analyze --fatal-warnings
- name: Check formatting
run: |
export PATH="$PWD/flutter/bin:$PATH"
dart format --set-exit-if-changed lib/ test/
# ── Test (gate) ──────────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-24.04
needs: [lint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
-o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
run: |
export PATH="$PWD/flutter/bin:$PATH"
python3 ci_stub_gen.py
- name: Run tests
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter test --reporter=expanded
# ── Android release build ───────────────────────────────────
build-android:
name: Build Android Release
runs-on: ubuntu-24.04
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java 17
run: |
set -e
apt-get update
apt-get install -y openjdk-17-jdk
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="$JAVA_HOME/bin:$PATH"
java -version
- name: Setup Flutter
run: |
set -e
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
-o /tmp/flutter.tar.xz
tar -xf /tmp/flutter.tar.xz -C "$PWD"
export PATH="$PWD/flutter/bin:$PATH"
flutter --version
- name: Install Android SDK command-line tools
run: |
set -e
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 /tmp/cmdline-tools.zip
unzip -q /tmp/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"
- name: Pre-cache Android platform artifacts
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter precache --android
- name: Accept Android SDK licenses
run: |
yes | "${HOME}/android-sdk/cmdline-tools/latest/bin/sdkmanager" --licenses || true
- name: Setup Python
run: |
set -e
python3 --version
- name: Install Python dependencies
run: |
python3 -m pip install --upgrade pip || true
pip install -r requirements.txt || true
- name: Install Flutter dependencies
run: |
export PATH="$PWD/flutter/bin:$PATH"
flutter pub get
- name: Generate stubs (lint gate support)
run: |
export PATH="$PWD/flutter/bin:$PATH"
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: |
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="$PWD/flutter/bin:$PATH"
flutter build apk --release
- name: Build App Bundle (release)
run: |
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export PATH="$PWD/flutter/bin:$PATH"
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: "Note: artifact storage not configured"
run: |
echo "WARNING: Gitea Actions artifact storage is not configured on this instance."
echo "Build outputs available in runner workspace:"
echo " APK: build/app/outputs/flutter-apk/app-release.apk"
echo " AAB: build/app/outputs/bundle/release/app-release.aab"
# ── iOS release build ───────────────────────────────────────
# Note: iOS builds require macOS with Xcode. We skip as a no-op
# on Linux runners. To enable, configure a macOS runner.
build-ios:
name: Build iOS Release
runs-on: ubuntu-24.04
needs: [test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check iOS build feasibility
run: |
echo "INFO: iOS builds require macOS host with Xcode."
echo "Our self-hosted runner runs Linux containers via act."
echo "Skipping iOS build — it will succeed as a no-op."
echo "To enable iOS builds, configure a native macOS runner."
# ── Publish Gitea Release ────────────────────────────────────
publish-release:
name: Publish Release
runs-on: ubuntu-24.04
needs: [build-android]
steps:
- name: Checkout
uses: actions/checkout@v4
- 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"
+67
View File
@@ -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).
+27 -3
View File
@@ -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,13 +47,27 @@ 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.
// 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 {
+99
View File
@@ -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()
+16
View File
@@ -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;
}
+18 -14
View File
@@ -28,10 +28,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
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();
@@ -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,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,
@@ -167,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,
),
);
+5 -1
View File
@@ -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;
+1 -2
View File
@@ -458,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()),
+2 -7
View File
@@ -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),
+14 -7
View File
@@ -35,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: '${AppConfig.appName} 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;
});
@@ -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;
+3 -2
View File
@@ -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,
+2 -6
View File
@@ -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,
),
+13 -8
View File
@@ -54,18 +54,19 @@ 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', () {
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';
@@ -87,13 +88,17 @@ void main() {
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',
);
});
+8 -2
View File
@@ -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,
);
});
}