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.gradleand 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
Flavorenum 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
manifestPlaceholdersper flavor -
Firebase: declare a
config:path per OS -
Signing: keep
signingConfigexplicit incustomConfigso 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<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:
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.gradlevia Fastlane (update_version+package_name) - Clean
build/,android/app/build/, andandroid/.gradle/(disk fills fast at N flavors) - Run
flutter build appbundle --release --flavor <name> - 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.yamlversion (name+code) - Clean Flutter / iOS build dirs
- Run
flutter build ipa --release --flavor <name> --build-number <code> - Extract dSYMs to a versioned folder before wipe (Crashlytics needs them)
- Upload via
xcrun altoolusing 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
enabledin JSON -
Per-flavor signing: honor
signingConfigalready 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.
Top comments (0)