Dev.to · 7 min read

Taming 70 Flutter Flavors: flavorizr + Batch CI for White-Label Releases

Taming 70 Flutter Flavors: flavorizr + Batch CI for White-Label Releases

How we ship dozens of branded photography apps from one Flutter codebase—without drowning in manual Xcode/Gradle edits or one-off store uploads. This is the approach we use at Kamero — an AI-powered event photography platform (kamero.ai) that delivers white-label guest apps for studios and photographers. The problem: white-label means real store binaries At Kamero, our product is a multi-tenant event photography app. Each photography studio often needs: Its own App Store / Play Store listing Its own bundle ID / applicationId Its own icon, splash, Firebase config, and deep-link host Its own signing key (especially on Android) A seed brand palette before any network call That is not “swap a hex in JSON and ship one binary.” Store policy, OAuth clients, push configs, and client branding push you toward flavors—one installable app per tenant. At around 70 flavors, the naive approach dies: Hand-editing android/app/build.gradle and Xcode schemes per client Remembering which tenants to release this week Rebuilding everything because one version bump was missed Filling the disk with 70× AAB/IPA intermediate artifacts We did not escape flavors. We industrialized them. The solution: three layers 1. flutter_flavorizr Declarative flavor defs → native projects + Dart enum 2. FlavorConfig (Dart) Per-flavor seed: title, splash, logo, brandColor, tenant ID + optional runtime color overlay from tenant profile 3. Batch CI scripts flavor_list_*.json → build only enabled → upload to stores flavorizr owns native packaging. FlavorConfig owns first-paint branding in Dart. Scripts own release fan-out so humans do not click “build” seventy times. Layer 1: managing flavors with flutter_flavorizr We use flutter_flavorizr (with local customizations so we can extend processors). Flavors live as declarative config under pubspec.yaml: flavorizr: ide: "vscode" app: android: flavorDimensions: "app" flavors: acme_w1: app: name: "Acme Studios" ios: bundleId: com.example.app.1 icon: assets/images/acme/logo.png firebase: config: config/acme/GoogleService-Info.plist android: applicationId: com.example.app.w1 icon: assets/images/acme/logo.png firebase: config: config/shared/google-services.json customConfig: manifestPlaceholders: '= [deepLinkHost: "1"]' versionCode: 15004 versionName: '"1-3.0.6"' signingConfig: signingConfigs.release Running flavorizr generates / refreshes: Android product flavors and flavorizr.gradle iOS schemes / build configs Per-flavor icons and Firebase plist/json wiring A Dart Flavor enum consumed at startup Naming convention: {clientSlug}_w{tenantId} (for example acme_w1). The wN suffix maps to a white-label / tenant ID used by the backend and feature gates. Why this works at scale New client: add a YAML block + assets + regenerate (instead of hand-editing Gradle and Xcode) Deep links: declare manifestPlaceholders per flavor Firebase: declare a config: path per OS Signing: keep signingConfig explicit in customConfig so Play uploads do not fail mysteriously Adding a client becomes a checklist, not archaeology. Layer 2: Dart-side FlavorConfig + runtime color overlay Native flavor only gets you package identity and assets. UI still needs brand tokens. At boot: Future main() async { WidgetsFlutterBinding.ensureInitialized(); // appFlavor comes from the native flavor / --flavor F.appFlavor = Flavor.values.firstWhere( (e) => e.name == appFlavor?.toLowerCase(), ); flavorConfig = F.appFlavor.getFlavorConfig()!; // … init OAuth / Firebase for this flavor … runApp(const ProviderScope(child: MyApp())); } Each enum case maps to a seed config: class FlavorConfig { String? appTitle; String splashImage; String? whiteLabelId; Color brandColor; Color? contentColor; // text/icons on brand surfaces String? logo; bool isLive; bool isPhoneRequiredOnSignup; bool isPhoneRequiredForProfileCompletion; bool get isWhiteLabel => whiteLabelId != null && whiteLabelId != '0'; } extension on Flavor { FlavorConfig? getFlavorConfig() { switch (this) { case Flavor.acme_w1: return FlavorConfig() ..appTitle = 'Acme Studios' ..splashImage = 'assets/images/acme/splash.png' ..logo = 'assets/images/acme/logo.png' ..whiteLabelId = '1' ..brandColor = const Color(0xFF3F51B5); // … one case per flavor … } } } Widgets do not hard-code one brand purple. Shared chrome reads helpers: Color getBrandColor() => whiteLabelModel?.brandColor ?? flavorConfig.brandColor; Color getContentColor() => whiteLabelModel?.effectiveContentColor ?? flavorConfig.contentColor ?? flavorConfig.brandColor; Runtime overlay After splash/welcome, we fetch the tenant profile. If the API returns a brandColor hex, we set a small in-memory model so AppBars, buttons, and loaders pick up the latest palette without rebuilding the store binary. Logos, splash, and app title stay flavor-seeded (store identity). Accent color can still move with the photographer’s profile. Derived surfaces keep the design coherent from one seed: Color get subtleBackground => Color.alphaBlend( brandColor.withAlpha((255 * 0.02).round()), const Color(0xFFF5F5F5), ); Feature gates are mostly per white-label ID (for example, hide create-event for some tenants). Not elegant forever—but explicit and reviewable next to the flavor map. Layer 3: CI scripts as the real alternative to manual releases flavorizr solves definition. It does not solve “build and upload 40 AABs tonight.” That is where batch scripts matter. Control plane: flavor_list_*.json Instead of hard-coding the release set in bash, we keep a JSON registry: { "flavors": [ { "name": "main_w0", "enabled": false, "priority": 1, "package_name": "com.example.app" }, { "name": "acme_w1", "enabled": true, "priority": 1, "package_name": "com.example.app.w1" } ] } enabled — include in tonight’s batch (flip without editing the shell) package_name — Android applicationId for Fastlane version bumps / upload Separate lists for Android and iOS Only enabled flavors run. If one flavor fails, the script continues and prints a summary at the end. Android batch script ./scripts/cicd/build_and_upload_android.sh \ --version-name "3.0.1" \ --version-code 15020 \ --track internal Per enabled flavor, sequentially: Bump version in flavorizr.gradle via Fastlane (update_version + package_name) Clean build/, android/app/build/, and android/.gradle/ (disk fills fast at N flavors) Run flutter build appbundle --release --flavor Upload the AAB with Fastlane + a Play service account JSON Clean again after upload (success or failure) Optional: pass --flavor acme_w1 to smoke-test one client before enabling the full set. iOS batch script Same idea, different store plumbing: Bump pubspec.yaml version (name+code) Clean Flutter / iOS build dirs Run flutter build ipa --release --flavor --build-number Extract dSYMs to a versioned folder before wipe (Crashlytics needs them) Upload via xcrun altool using per-flavor App Store Connect API keys ./scripts/cicd/build_and_upload_ios.sh \ --version-name "3.0.1" \ --version-code 15020 Why scripts beat hand-rolled CI jobs for every flavor Selective releases: toggle enabled in JSON Per-flavor signing: honor signingConfig already in flavorizr output Disk pressure: aggressive clean before/after each flavor Debuggability: timestamped logs under build_logs/ Partial failure: continue the matrix; summarize failures Local / CI agnostic: same bash on a release Mac or a runner This is our practical alternative to maintaining seventy separate CI jobs by hand: one pipeline shape, data-driven flavor set. End-to-end release flow New client → assets/ + Firebase config/ → flavorizr YAML entry → regenerate native + Flavor enum → FlavorConfig case (seed colors, splash, whiteLabelId) → row in flavor_list_android.json / flavor_list_ios.json (enabled: false) → first manual / --flavor smoke build → enable in JSON → batch script with shared version-name + version-code → Play / App Store Connect → runtime profile may still refresh brandColor later Humans decide which tenants ship. Machines do the repetitive build/sign/upload loop. What we learned Flavors are a distribution boundary, not a theming API. Use them for store identity; keep UI tokens thin (FlavorConfig + optional runtime overlay). Declarative generation (flavorizr) beats hand-edited native projects once you pass roughly ten clients. A JSON enable-list is the release feature flag for white-label CI. Do not bury the release set inside the shell script. Sequential builds + aggressive cleaning are boring and necessary. Parallelizing 70 Flutter release builds without a disk strategy fails noisily. Per-flavor signing and ASC API keys must be first-class in the checklist. Most “batch upload” failures are identity/signing, not Dart. Runtime branding still helps—photographers can change brand colors without waiting for another store review—while splash/icon/package stay flavor-owned. Conclusion We did not pretend seventy store apps are “one binary.” At Kamero, we accepted flavors, then made them operable: flutter_flavorizr for consistent native + Dart flavor scaffolding FlavorConfig for seed branding and tenant ID wiring Batch CI scripts + flavor_list JSON so releases are toggles and version args, not tribal knowledge If your white-label story requires separate listings, invest in generation + batching early. The cost of flavors is not the YAML—it is the release matrix. Scripts are how we keep that matrix boring. Building something similar for photographers or event platforms? Check out kamero.ai—happy to compare notes on white-label Flutter delivery. Discussion Do you generate flavors (flavorizr / custom codegen) or maintain native projects by hand? And for releases: one mega CI matrix, or a data-driven enable-list like ours? War stories welcome—especially signing mismatches and “disk filled on flavor #37.” Drop a comment, or find us at kamero.ai.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News