A production Flutter template where every decision is written down — and the reason it was made.
Riverpod 3 · go_router 17 · Dio 5 · Material 3 · flavors · Firebase — wired together, tested, and explained.
git clone https://github.com/Iamsdt/flutter_starter.git my_app && cd my_app
flutter pub get && dart run build_runner build
flutter run -t lib/main_dev.dart --dart-define-from-file=.env.devMost Flutter templates hand you a folder structure and wish you luck. You get
features/, core/, a Riverpod provider or two, and no answer to the question
that actually costs you a week: why is it like this, and what happens when I
change it?
This one is built to be copied by juniors and by AI coding agents, which is a higher bar than "it runs." Every non-obvious decision carries a comment explaining the failure it prevents — usually a real bug that was in this repo before it got fixed:
// Stays a QueuedInterceptor: a token refresh must not race concurrent
// requests, or several of them will each trigger their own refresh.
final class AuthInterceptor extends QueuedInterceptor with NetworkLogger {// Accepts `1` or `true`. `bool.fromEnvironment` alone would only accept
// `true`, so the `IS_JWT=1` the dotenv files used would have silently
// become `false` — disabling token refresh with no error anywhere.
static bool get isJwt => ...When you copy a pattern from here, you inherit the reasoning with it. That is the whole product.
| Area | Choice | Why it's this way |
|---|---|---|
| State & DI | Riverpod 3 + code generation | Compile-time-safe providers, no manual Provider boilerplate |
| Routing | go_router 17 | One declarative redirect guards the entire app |
| HTTP | Dio 5 + auth / error interceptors | Token refresh that survives concurrent requests |
| Config | --dart-define-from-file |
Compiled in, not shipped as a readable asset |
| Errors | Sealed exception hierarchy | switch over failures is exhaustive at compile time |
| Theming | Material 3 from one seed colour | Rebrand by changing a single Color |
| i18n | JSON read at runtime (en, bn) | Add a language without a code-gen step |
| Storage | SharedPreferencesAsync + secure storage |
Tokens in the keychain, preferences in prefs |
| Logging | logger behind TaggedLogger |
Swappable, with a separate Crashlytics threshold |
| Testing | mocktail + http_mock_adapter |
208 tests, including the interceptor paths |
| Flavors | dev / staging / prod | Separate entrypoints, Android product flavors |
| Firebase | core, analytics, crashlytics | Guarded, so a fresh clone runs with no Firebase project |
| Platforms | Android, iOS, web, Linux, macOS, Windows |
# 1. Claim the template's identifiers FIRST — an application ID cannot be
# changed once the app is published.
# android/app/build.gradle.kts applicationId + namespace
# ios/Runner.xcodeproj PRODUCT_BUNDLE_IDENTIFIER
# web/manifest.json, web/index.html
# pubspec.yaml name + description
# 2. Dependencies
flutter pub get
# 3. Environment config (repo root; .env.* is gitignored)
for f in dev staging prod; do cp "example.env.$f" ".env.$f"; done
# then edit .env.dev and set BASE_URL
# 4. Firebase — the stub lets you skip a real Firebase project entirely
cp lib/firebase_options.example.dart lib/firebase_options.dart
# or: dart pub global activate flutterfire_cli && flutterfire configure
# 5. Code generation
dart run build_runner build
# 6. Run
flutter run -t lib/main_dev.dart --dart-define-from-file=.env.dev
flutter run -t lib/main_staging.dart --dart-define-from-file=.env.staging
flutter run -t lib/main_prod.dart --dart-define-from-file=.env.prodThe
--dart-define-from-fileflag is required, not optional. Omitting it fails silently:AppEnvfalls back to defaults,BASE_URLis empty, and every request fails. Worth a shell alias or a VS Code launch config.
Requires Flutter 3.44.0+ and Dart 3.12.0+. Full walkthrough: docs/01-getting-started.md.
Splash, login, and logout are three chances to forget a navigation call — and an earlier version of this repo forgot the third, leaving signed-out users parked on the home screen. The fix wasn't another listener. It was deleting all of them.
router.dart now holds a single redirect plus a
refreshListenable. Screens never call go() or push() to react to auth state:
redirect: (context, state) {
// Session restore hasn't finished — hold on splash rather than guessing.
// Sending a signed-in user to login for one frame is a visible flash and
// loses any deep link they arrived with.
if (!refresh.hasRestoredSession) { ... }
final signedIn = ref.read(authProvider).value != null;
if (!signedIn) return AppRoute.login.path;
...
return null; // every other location passes through, so deep links survive
}Session restore reads the token store as the single source of truth. No parallel "is logged in" boolean to drift out of sync.
flutter_dotenv ships every environment's config inside every build as a
plaintext asset, recoverable by unzipping the APK. This template uses
String.fromEnvironment constants supplied by --dart-define-from-file, so
values are baked into the binary and tree-shaken like any other constant.
AppEnv.isProduction derives from the entrypoint, never from the env file,
so the two cannot disagree with the binary you actually shipped.
It's still not a secret store. A determined attacker can recover constants from a binary. Real secrets belong on a server.
sealed class BaseAppException { ... } // NetworkException, TokenException, ...A sealed hierarchy makes switch exhaustive — add a failure mode and the
compiler finds every site that needs updating. Exceptions carry an
AppErrorCode, not a sentence, because the layer raising them has no locale.
Presentation resolves the code with context.messageForCode(...).
core/ never interrupts the user. Interceptors attach a typed exception and let
the caller decide; deciding to show a snackbar is a presentation choice.
AuthInterceptor is a
QueuedInterceptor with a dedicated refresh client — created once so the
connection pool is reused, and deliberately free of auth interceptors so it can't
recurse. It's injectable, which is the only reason the refresh path is reachable
from a test at all.
Session revocation is broadcast as an event stream, so a 401 that can't be
recovered signs the user out from one place.
Strings live in assets/i18n/*.json and are read at runtime — no ARB, no
gen-l10n, no generated Dart in the repo. Adding a language is one new file plus
one entry in supportedLocales.
The safety net that replaces the compiler is
localization_test.dart, which fails if a key
has no translation, a translation has no key, a locale has fallen behind English,
or a translation dropped a {placeholder}.
Inter is bundled under assets/fonts/inter/ rather than pulled through
google_fonts, which fetches over HTTP at first paint. Weights 400/500/600/700
plus a real italic face cover the whole Material 3 text theme.
lib/
main.dart main_dev.dart main_staging.dart main_prod.dart
bootstrap.dart shared startup — ProviderScope, Firebase, ErrorWidget
app/ MyApp, AppState, AppMessenger, theme/locale providers
core/
env/ AppEnv — flavor + compile-time config
logging/ TaggedLogger, AppLog, AnalyticsLogger mixin
network/ Dio provider, interceptors, exceptions, models,
session-revocation events, connectivity stream
storage/ SharedPrefs, SecureStorage, AuthTokenStore
firebase/ FirebaseSupport (availability guard)
observers/ PObserver (Riverpod), GoNavigatorObserver
extensions/ BuildContext, Widget, Num, String, Date helpers
gen/ flutter_gen asset/font references
features/<name>/
data/ models, API interface, repository + provider
application/ notifiers
presentation/ screens and widgets
l10n/ AppLocalizations delegate + LocaleKeys
routing/ AppRoute enum, GoRouter provider
shared/ breakpoints, validators
theme/ AppTheme, AppColors
Three layers per feature, one direction of dependency:
presentation → application → data. See
docs/02-architecture.md for the rules and
docs/05-adding-a-feature.md for an end-to-end
walkthrough.
dart format .
flutter analyze --fatal-infos
flutter test --dart-define-from-file=.env.dev
# Needs a device or emulator — `flutter test` does not pick these up.
flutter test integration_test --dart-define-from-file=.env.dev208 unit and widget tests, 0 analyzer issues. They cover the parts that
usually go untested: the auth interceptor's refresh path, error mapping, router
redirects for login / logout / session restore, token storage, and localization
completeness. mocktail for fakes, http_mock_adapter to program Dio responses.
CI runs format, analyze, and test on every push and PR (.github/workflows/ci.yml).
| Doc | What it explains |
|---|---|
| Getting Started | Prerequisites, setup, env files, running & verifying |
| Architecture | Folder structure, layer rules, DI, code-gen workflow |
| Networking | Dio client, interceptors, error handling, API models |
| State Management | Riverpod providers, notifiers, hooks |
| Adding a Feature | End-to-end walkthrough from model to screen |
| Routing | GoRouter, route definitions, navigation patterns |
| Theming | Colors, fonts, dark mode, responsive breakpoints |
| Storage | SharedPreferences, SecureStorage, StorageKeys |
| Firebase | Setup, Analytics mixin, Crashlytics, Messaging |
guidelines.md is the one-page cheat-sheet — theme, i18n, responsive layout, networking, logging, flavors.
A starter that hides its rough edges just moves the surprise to your second sprint. These are the ones that are real:
- iOS flavors are not wired up. Android has three product flavors; iOS has a
single
Runnerscheme, soflutter build ios --flavor devfails. The groundwork is in place —ios/Flutter/{Dev,Staging,Prod}.xcconfigand a$(BUNDLE_ID_SUFFIX)/$(APP_DISPLAY_NAME)-drivenInfo.plist— but the per-flavor build configurations and shared schemes have to be created in Xcode. Steps: docs/01-getting-started.md. firebase_messagingandfirebase_performanceare declared but not wired. They add native SDK weight and a privacy-policy obligation to every build. Wire them up or drop them before you ship.riverpod_lintdoesn't run from the command line on Flutter 3.44 —analysis_options.yamlexplains why and how to re-test after an upgrade. The lints still reach you through the IDE's analysis server.flutter_svg,path_provider,cryptoandgo_router_builderare pre-wired but unused. They're marked as such inpubspec.yaml, so the first feature that needs one doesn't have to justify a dependency change.
Why no freezed? No stable release is compatible with the analyzer version
Flutter 3.44 pins. Models are plain classes with json_serializable; Dart 3
sealed classes and pattern matching cover most of what freezed provided.
Why hooks_riverpod only? It re-exports everything in flutter_riverpod.
Declaring both meant two import paths to the same symbols, with different files
picking different ones.
Why carets everywhere in pubspec.yaml? The committed pubspec.lock is what
makes builds reproducible. Exact pins in the pubspec duplicate the lockfile's job
and then go stale.
Issues and pull requests are welcome. Before opening a PR:
dart format . && flutter analyze --fatal-infos && flutter test --dart-define-from-file=.env.devIf you change a non-obvious behaviour, add the comment explaining why. That convention is the point of this repo.
MIT © 2026 Shudipto Trafder — use it, ship it, sell it. Just keep the copyright notice.
Inter is the one bundled font and is not covered by the MIT grant above.
It ships under the SIL Open Font License — see assets/fonts/inter/OFL.txt.