505 lines
18 KiB
Python
505 lines
18 KiB
Python
#!/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
|
||
light_props = [
|
||
("primarySeed", hex_to_rgba(c["primary"])),
|
||
("secondarySeed", 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)),
|
||
("accent", hex_to_rgba(c["accent_dark"])),
|
||
("webViewBorder", hex_to_rgba(c["medium"], 0.15)),
|
||
("webViewBackground", hex_to_rgba(c["white"])),
|
||
("controlsGradientStart", hex_to_rgba(c["primary_muted"])),
|
||
("controlsGradientEnd", hex_to_rgba(c["accent_muted"])),
|
||
("controlsShadow", hex_to_rgba(c["black"], 0.14)),
|
||
("statusText", hex_to_rgba(c["text"])),
|
||
]
|
||
|
||
# Dark mode
|
||
dark_props = [
|
||
("primarySeed", hex_to_rgba(c["primary_light"])),
|
||
("secondarySeed", hex_to_rgba(c["accent_light"])),
|
||
("scaffoldGradientStart", hex_to_rgba(c["black"])),
|
||
("scaffoldGradientEnd", darken(c["primary_dark"], 0.30)),
|
||
("shellSurface", hex_to_rgba(c["black"], 0.85)),
|
||
("shellBorder", hex_to_rgba(c["accent_light"], 0.20)),
|
||
("accent", hex_to_rgba(c["primary_light"])),
|
||
("webViewBorder", hex_to_rgba(c["accent_light"], 0.20)),
|
||
("webViewBackground", hex_to_rgba(c["black"])),
|
||
("controlsGradientStart", hex_to_rgba(c["primary_dark"])),
|
||
("controlsGradientEnd", hex_to_rgba(c["accent_dark"])),
|
||
("controlsShadow", hex_to_rgba(c["black"], 0.27)),
|
||
("statusText", hex_to_rgba(c["light"])),
|
||
]
|
||
|
||
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")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|
||
|
||
# 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()
|