Build and release
A Flutter app is a Dart program plus two ordinary native projects that host it. android/
is a Gradle project you could open in Android Studio. ios/ is an Xcode workspace with a
Runner target and CocoaPods. The flutter CLI drives both: flutter build apk runs
Gradle, flutter build ipa runs xcodebuild. This page goes through how configuration
gets into a build, what in the native shells is Footrace-specific, and how a release build
is made.
The Makefile
Section titled “The Makefile”The README calls it “a Makefile rather than a justfile so nothing extra needs installing”.
Every target is a thin wrapper over a flutter or dart command (app/Makefile:1-87).
| Target | Runs | Notes |
|---|---|---|
deps |
flutter pub get here and in widgetbook/ |
|
format / format-check |
dart format . / with --set-exit-if-changed |
|
analyze |
flutter analyze --fatal-infos |
infos fail the build, not just warnings |
test |
flutter test |
everything under test/ |
check |
format-check analyze test |
what CI’s check job runs, minus coverage (app/Makefile:34) |
coverage |
flutter test --coverage + tool/check_coverage.dart --min 70 |
see Testing |
integration |
scripts/integration.sh |
emulator + local backend |
apk |
flutter build apk --debug |
same as CI’s build job |
icons |
flutter_launcher_icons + flutter_native_splash:create |
regenerates native icon and splash files |
run-dev |
flutter run --dart-define-from-file=env/dev.json |
app/Makefile:75-76 |
release-android |
flutter build appbundle and flutter build apk, both --release with env/prod.json |
app/Makefile:82-84 |
release-ios |
flutter build ipa --release with env/prod.json and ios/ExportOptions.plist |
app/Makefile:86-87 |
storybook* |
build, serve, install or analyze the Widgetbook app | see Design system |
BUILD_NUMBER ?= 1 (app/Makefile:80) feeds --build-number for both release targets, so
make release-android BUILD_NUMBER=42 produces version 1.0.0+42.
Configuration: –dart-define and env/*.json
Section titled “Configuration: –dart-define and env/*.json”There is no .env file and no runtime config. Configuration is compiled in.
--dart-define=KEY=value makes a constant available to String.fromEnvironment('KEY') and
bool.fromEnvironment('KEY'), which the compiler evaluates at build time. The closest native
analogues are a buildConfigField in Gradle (BuildConfig.API_BASE_URL) or a custom
build setting that Xcode substitutes into Info.plist. As with those, changing a value
means rebuilding: hot reload does not pick it up, and a restart of flutter run with
different flags does.
--dart-define-from-file=env/dev.json reads the same keys from a JSON object. There are
three files:
| File | API_BASE_URL |
DEV_TOOLS |
FAKE_GPS |
|---|---|---|---|
env/dev.json |
http://10.0.2.2:4001 |
true |
false |
env/staging.json |
https://staging.api.footrace.app |
true |
false |
env/prod.json |
https://api.footrace.app |
false |
false |
10.0.2.2 is the Android emulator’s alias for the host machine, so make run-dev targets
the emulator. The iOS simulator would need localhost:4001 passed by hand. Port 4001,
not Phoenix’s default 4000, because 4000 is often taken on the owner’s machine (CLAUDE.md).
Whether staging.api.footrace.app and api.footrace.app exist yet could not be verified
from the repo.
AppConfig
Section titled “AppConfig”lib/core/config/app_config.dart is the only file that reads the defines
(app/lib/core/config/app_config.dart:41-48):
static const fromEnvironment = AppConfig( apiBaseUrl: String.fromEnvironment( 'API_BASE_URL', defaultValue: 'http://localhost:4000', ), devTools: bool.fromEnvironment('DEV_TOOLS', defaultValue: kDebugMode), fakeGps: bool.fromEnvironment('FAKE_GPS'),);It is exposed through appConfigProvider (app/lib/core/config/app_config.dart:52), which
tests override with a literal AppConfig(...). Three details worth knowing:
DEV_TOOLSdefaults tokDebugMode, so a debug build has the GPS diagnostics route and the “Race a bot (dev)” button unless you passDEV_TOOLS=false; a release build has them only if you passtrue.fakeGpsis a getter,!kReleaseMode && _fakeGps(app/lib/core/config/app_config.dart:29).kReleaseModeis itself a compile-time constant, so in a release build the simulator branch is dead code whatever the flag says. A profile build still honours it.- The socket URL is derived, not configured:
http://host:4001becomesws://host:4001/socket/websocket,httpsbecomeswss(:33-39). That path is the Phoenix endpoint’ssocket "/socket", FootraceWeb.UserSocket(backend/lib/footrace_web/endpoint.ex:15) with the/websockettransport suffix (see Realtime).
On iOS you can see the defines in transit: flutter build writes them base64-encoded into
DART_DEFINES in ios/Flutter/Generated.xcconfig, a gitignored file the Xcode build reads.
App identity and version
Section titled “App identity and version”| Android | iOS | |
|---|---|---|
| Store id | applicationId = "com.footraceapp.footrace" (app/android/app/build.gradle.kts:39) |
PRODUCT_BUNDLE_IDENTIFIER = com.footraceapp.footrace (app/ios/Runner.xcodeproj/project.pbxproj:495) |
| Display name | android:label="Footrace" (app/android/app/src/main/AndroidManifest.xml:11) |
CFBundleDisplayName (app/ios/Runner/Info.plist:9-10) |
| Version | versionName/versionCode from flutter.versionName/versionCode (app/android/app/build.gradle.kts:48-49) |
$(FLUTTER_BUILD_NAME) / $(FLUTTER_BUILD_NUMBER) (app/ios/Runner/Info.plist:21-26) |
Both versions come from one line, version: 1.0.0+1 in app/pubspec.yaml:5: the part
before + is the marketing version, the part after is the build number. --build-number
overrides the second part. The Kotlin package and Android namespace are still
com.footraceapp.footrace_flutter (app/android/app/build.gradle.kts:29); that only names
the generated R class and MainActivity’s package, not the installed app. There are no
build flavors: dev, staging and prod are the same binary identity with different defines,
so they cannot be installed side by side.
How the native shells host Flutter
Section titled “How the native shells host Flutter”Android
Section titled “Android”The app module is a normal Android application with two plugins
(app/android/app/build.gradle.kts:3-7): com.android.application and
dev.flutter.flutter-gradle-plugin. The second one is loaded from the Flutter SDK, found
through flutter.sdk in android/local.properties (app/android/settings.gradle.kts:2-11),
and flutter { source = "../.." } points it at the Dart package root
(app/android/app/build.gradle.kts:87-89). During assemble, that plugin compiles the
Dart code (AOT to a native libapp.so in release, a kernel snapshot in debug), bundles
flutter_assets/ and adds the engine as a dependency. AGP is 9.1.0 and Kotlin 2.4.0
(app/android/settings.gradle.kts:20-24); CI pins JDK 17 because Gradle 9 needs it.
The whole native UI is one line (app/android/app/src/main/kotlin/com/footraceapp/footrace_flutter/MainActivity.kt:5):
class MainActivity : FlutterActivity()FlutterActivity creates a FlutterView that fills the window and starts the engine,
which runs main() from lib/main.dart. There are no other activities, fragments or
layouts. Plugins (geolocator, secure storage, the foreground task) each ship a native
half; Flutter generates GeneratedPluginRegistrant.java to register them with the engine,
driven by the flutterEmbedding meta-data in the manifest
(app/android/app/src/main/AndroidManifest.xml:43-45).
The manifest is where Footrace differs from the template
(app/android/app/src/main/AndroidManifest.xml:2-9, :36-40):
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /><uses-permission android:name="android.permission.FOREGROUND_SERVICE" /><uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /><uses-permission android:name="android.permission.POST_NOTIFICATIONS" /><uses-permission android:name="android.permission.WAKE_LOCK" /><!-- ... --><service android:name="com.pravera.flutter_foreground_task.service.ForegroundService" android:foregroundServiceType="location" android:exported="false" />That service is the flutter_foreground_task plugin’s Android Service. It hosts a
second Dart isolate running LocationTaskHandler, which is what keeps GPS sampling with
the screen locked (Tracking). The location foreground service
type plus FOREGROUND_SERVICE_LOCATION is what Android 14 requires for that; the comment
says not to rename it. LaunchTheme and NormalTheme (:19, :27-30) are the window
themes shown before and behind the first Flutter frame.
Release builds turn on R8 (isMinifyEnabled, isShrinkResources,
app/android/app/build.gradle.kts:71-72). android/app/proguard-rules.pro keeps
com.pravera.flutter_foreground_task.** and com.baseflow.geolocator.**, because the
service’s entry point is reached by name and R8 would otherwise strip it.
ios/Runner.xcworkspace is the thing to open in Xcode, as with any CocoaPods project.
Runner is a UIKit app, which you will recognise:
AppDelegatesubclassesFlutterAppDelegateand registers plugins once the engine exists (app/ios/Runner/AppDelegate.swift:4-16).SceneDelegateis an empty subclass ofFlutterSceneDelegate(app/ios/Runner/SceneDelegate.swift:4-6), andInfo.plistdeclares a scene manifest pointing at it (app/ios/Runner/Info.plist:29-49).Main.storyboard’s only view controller hascustomClass="FlutterViewController". That view controller is the whole UI, the same roleFlutterActivityplays on Android.
The FlutterImplicitEngineDelegate / didInitializeImplicitFlutterEngine pair in
AppDelegate is newer than what you may remember from older Flutter templates (which
called GeneratedPluginRegistrant.register(with: self) in didFinishLaunching). It
arrives with Flutter’s move to the UIScene lifecycle. The shape is template code; nothing
Footrace-specific lives there.
Dart is built by two “Run Script” build phases that call
$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh with build and
embed_and_thin (app/ios/Runner.xcodeproj/project.pbxproj:354, :300). FLUTTER_ROOT
and the other variables come from ios/Flutter/Generated.xcconfig, which
Debug.xcconfig and Release.xcconfig include along with the Pods config. That file is
regenerated by flutter pub get and gitignored. If Xcode complains it is missing, run
flutter pub get.
The Podfile is the Flutter template: it finds FLUTTER_ROOT from Generated.xcconfig
(app/ios/Podfile:13-24) and calls flutter_install_all_ios_pods, which adds one pod per
plugin (:30-37). The platform :ios line is commented out (:2); the deployment target
is set in the Xcode project instead, to 15.0 (app/ios/Runner.xcodeproj/project.pbxproj:472).
Info.plist carries the Footrace-specific keys (app/ios/Runner/Info.plist:69-83):
| Key | Value | Why |
|---|---|---|
NSLocationWhenInUseUsageDescription |
“FootRace uses your location…” | first permission prompt |
NSLocationAlwaysAndWhenInUseUsageDescription |
“FootRace keeps tracking…” | the “Always” upgrade |
UIBackgroundModes |
location, fetch |
keeps GPS alive while locked |
BGTaskSchedulerPermittedIdentifiers |
com.pravera.flutter_foreground_task.refresh |
the plugin’s background refresh task |
ITSAppUsesNonExemptEncryption |
false |
skips the export-compliance question in App Store Connect |
The usage strings spell it “FootRace” while the display name is “Footrace”. The README’s
troubleshooting section says to check UIBackgroundModes first if tracking stops in the
background on iOS.
Icons and splash
Section titled “Icons and splash”make icons runs two dev dependencies whose config lives in pubspec.yaml
(app/pubspec.yaml:66-81):
flutter_launcher_iconsreadsassets/brand/icon.pngandassets/brand/adaptive_icon.png(foreground, on#023047) and writes the Androidmipmap-*files and the iOSAppIcon.appiconset.flutter_native_splashreadsassets/brand/splash_icon.pngon#023047and writes the Android launch drawables andvalues-v31styles (the Android 12 splash API) and the iOSLaunchScreen.storyboardassets.
The generated files are committed (app/Makefile:68-69), so nobody needs to run this
unless the source images change. It is the scripted version of dragging images into an
asset catalog by hand.
Signing and release builds
Section titled “Signing and release builds”Android
Section titled “Android”android/app/build.gradle.kts reads four values from android/key.properties or, failing
that, from FOOTRACE_KEYSTORE_PATH, FOOTRACE_KEYSTORE_PASSWORD, FOOTRACE_KEY_ALIAS and
FOOTRACE_KEY_PASSWORD (app/android/app/build.gradle.kts:13-26). With all four present it
creates a release signing config. Without them, the release build type falls back to the
debug key and prints a warning (:63-70):
signingConfig = if (hasReleaseSigning) { signingConfigs.getByName("release")} else { println("footrace: no release keystore configured, signing with the debug key") signingConfigs.getByName("debug")}That fallback means a release build never fails for lack of a key, and also that a
debug-signed “release” APK cannot be installed over a store build. key.properties is
gitignored; android/key.properties.example shows the format. To ship:
cp android/key.properties.example android/key.properties # fill in the keystore path and passwordsmake release-android BUILD_NUMBER=42# build/app/outputs/bundle/release/app-release.aab -> Play Console# build/app/outputs/flutter-apk/app-release.apk -> sideloadmake release-ios needs ios/ExportOptions.plist, which is gitignored. The example
(ios/ExportOptions.plist.example) sets method to app-store-connect, automatic signing
and a placeholder teamID. flutter build ipa archives with xcodebuild and exports
with that plist, which is the command-line version of Product > Archive and “Distribute
App” in the Organizer. Signing itself uses whatever team and automatic signing settings
Xcode has for Runner; open the workspace once and set the team if the build complains.
Uploading the .ipa (Transporter, xcrun altool, or Xcode) is not scripted. The README
tracks store accounts and uploads in Linear issue ELB-1660; whether any store listing
exists yet could not be verified from the code.
.github/workflows/ci.yml runs on pushes to main, every pull request, and manual
dispatch, and cancels an in-flight run when the same ref is pushed again
(app/.github/workflows/ci.yml:3-13). All jobs read the Flutter version from
.tool-versions (flutter 3.47.2-stable) so CI and asdf agree.
| Job | What it proves | Lines |
|---|---|---|
check |
format, analyze with --fatal-infos, tests, 70 % coverage |
app/.github/workflows/ci.yml:16-45 |
build-android |
the debug APK builds on JDK 17 | :47-70 |
storybook |
Widgetbook analyzes and builds for web | :75-98 |
integration |
the full race on an API 34 emulator against the real backend; manual only | :100-192 |
No CI job builds a release artifact or touches signing, and there is no iOS job
(app/.github/workflows/ci.yml:72-73). Release builds are made on the owner’s machine with
the Make targets above.
The integration job checks out Elixir-Bytes/footrace next to the app using a
BACKEND_REPO_TOKEN secret, and fails with a warning if it cannot (:125-140). It resets
and seeds the backend database with mix ecto.reset and mix footrace.seed_dev
(backend/lib/mix/tasks/footrace/seed_dev.ex:15), starts mix phx.server on 4001 in the
background, and calls scripts/integration.sh --no-reset inside
reactivecircus/android-emulator-runner (:178-192). Day 13
reads that script line by line.
Quick reference
Section titled “Quick reference”make run-dev # emulator, local backend, dev toolsflutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \ --dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=true # emulator, simulated GPSflutter run --dart-define=API_BASE_URL=http://localhost:4001 # iOS simulatormake release-android BUILD_NUMBER=42make release-ios BUILD_NUMBER=42make iconsThe simulated-GPS line spells out all three defines rather than mixing env/dev.json
with an extra --dart-define, because how the two combine for the same key was not
verified here.