#!/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'(CFBundleDisplayName\s*)[^<]*()', rf'\1{app_name}\2', content, ) # Replace CFBundleName value content = re.sub( r'(CFBundleName\s*)[^<]*()', 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'()#[^<]*', rf'\1{c["primary"]}', content, ) content = re.sub( r'()#[^<]*', rf'\1{c["primary_dark"]}', content, ) content = re.sub( r'()#[^<]*', rf'\1{c["accent"]}', 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()