Skip to content

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

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_TOOLS defaults to kDebugMode, so a debug build has the GPS diagnostics route and the “Race a bot (dev)” button unless you pass DEV_TOOLS=false; a release build has them only if you pass true.
  • fakeGps is a getter, !kReleaseMode && _fakeGps (app/lib/core/config/app_config.dart:29). kReleaseMode is 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:4001 becomes ws://host:4001/socket/websocket, https becomes wss (:33-39). That path is the Phoenix endpoint’s socket "/socket", FootraceWeb.UserSocket (backend/lib/footrace_web/endpoint.ex:15) with the /websocket transport 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.

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.

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:

  • AppDelegate subclasses FlutterAppDelegate and registers plugins once the engine exists (app/ios/Runner/AppDelegate.swift:4-16).
  • SceneDelegate is an empty subclass of FlutterSceneDelegate (app/ios/Runner/SceneDelegate.swift:4-6), and Info.plist declares a scene manifest pointing at it (app/ios/Runner/Info.plist:29-49).
  • Main.storyboard’s only view controller has customClass="FlutterViewController". That view controller is the whole UI, the same role FlutterActivity plays 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.

make icons runs two dev dependencies whose config lives in pubspec.yaml (app/pubspec.yaml:66-81):

  • flutter_launcher_icons reads assets/brand/icon.png and assets/brand/adaptive_icon.png (foreground, on #023047) and writes the Android mipmap-* files and the iOS AppIcon.appiconset.
  • flutter_native_splash reads assets/brand/splash_icon.png on #023047 and writes the Android launch drawables and values-v31 styles (the Android 12 splash API) and the iOS LaunchScreen.storyboard assets.

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.

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:

Terminal window
cp android/key.properties.example android/key.properties # fill in the keystore path and passwords
make release-android BUILD_NUMBER=42
# build/app/outputs/bundle/release/app-release.aab -> Play Console
# build/app/outputs/flutter-apk/app-release.apk -> sideload

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

Terminal window
make run-dev # emulator, local backend, dev tools
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \
--dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=true # emulator, simulated GPS
flutter run --dart-define=API_BASE_URL=http://localhost:4001 # iOS simulator
make release-android BUILD_NUMBER=42
make release-ios BUILD_NUMBER=42
make icons

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