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

> Source: <https://dev.to/kamero/taming-70-flutter-flavors-flavorizr-batch-ci-for-white-label-releases-54fl>
> Published: 2026-08-04 11:35:24+00:00

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 (

At Kamero, our product is a multi-tenant event photography app. Each photography studio often needs:

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:

`android/app/build.gradle`

and Xcode schemes per clientWe did not escape flavors. We **industrialized** them.

```
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.

We use [flutter_flavorizr](https://pub.dev/packages/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:

`flavorizr.gradle`

`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.

`manifestPlaceholders`

per flavor`config:`

path per OS`signingConfig`

explicit in `customConfig`

so Play uploads do not fail mysteriouslyAdding a client becomes a checklist, not archaeology.

Native flavor only gets you package identity and assets. UI still needs brand tokens. At boot:

```
Future<void> 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:

``` js
Color getBrandColor() =>
    whiteLabelModel?.brandColor ?? flavorConfig.brandColor;

Color getContentColor() =>
    whiteLabelModel?.effectiveContentColor ??
    flavorConfig.contentColor ??
    flavorConfig.brandColor;
```

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:

``` js
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.

flavorizr solves *definition*. It does not solve “build and upload 40 AABs tonight.” That is where batch scripts matter.

`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 / uploadOnly enabled flavors run. If one flavor fails, the script continues and prints a summary at the end.

```
./scripts/cicd/build_and_upload_android.sh \
  --version-name "3.0.1" \
  --version-code 15020 \
  --track internal
```

Per enabled flavor, sequentially:

`flavorizr.gradle`

via Fastlane (`update_version`

+ `package_name`

)`build/`

, `android/app/build/`

, and `android/.gradle/`

(disk fills fast at N flavors)`flutter build appbundle --release --flavor <name>`

Optional: pass `--flavor acme_w1`

to smoke-test one client before enabling the full set.

Same idea, different store plumbing:

`pubspec.yaml`

version (`name+code`

)`flutter build ipa --release --flavor <name> --build-number <code>`

`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
```

`enabled`

in JSON`signingConfig`

already in flavorizr output`build_logs/`

This is our practical alternative to maintaining seventy separate CI jobs by hand: **one pipeline shape, data-driven flavor set.**

```
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.

`FlavorConfig`

+ optional runtime overlay).We did not pretend seventy store apps are “one binary.” At [Kamero](https://kamero.ai), we accepted flavors, then made them operable:

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](https://kamero.ai)—happy to compare notes on white-label Flutter delivery.

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](https://kamero.ai).
