A production-ready, community-friendly Flutter starter built with BLoC, repository pattern, responsive UI, role-based access control, internationalization, and multi-environment support. It is designed to help you move from prototype to maintainable product faster across mobile, web, and desktop.
Useful links: Live Demo · Wiki · Transformation Log · Report an Issue · Contributing
- Production-oriented structure with clear separation between presentation, business logic, and data access.
- Ready-to-use authentication, role-based routing, user management, localization, theming, and dashboard flows.
- Local mock mode for rapid development and production mode for real API integration.
- Works as an open-source base project for teams, side projects, internal products, and community contributions.
The screenshots below are included to help contributors and adopters understand the current UX quickly before cloning or running the project.
| Dark Login | Light Login |
|---|---|
![]() |
![]() |
| Login | Edit User |
|---|---|
![]() |
![]() |
- Login with username and password
- Registration flow
- Forgot password flow
- OTP send and verify flow
- Single sign-on (opt-in): the sign-in method is chosen at build time with
AUTH_PROVIDER.jwt, the default, is the password and OTP sign-in above against the template's backend, andoidcsigns users in through any OpenID Connect provider, such as Keycloak, Zitadel, Auth0 or Microsoft Entra ID. See Single Sign-On and docs/oidc.md. - Token storage — JWT and refresh tokens are persisted via
flutter_secure_storage(iOS Keychain, Android custom AES-GCM ciphers). Keychain accessibility is pinned tofirst_unlock_this_deviceso tokens stay on the device (no iCloud Keychain sync). Non-secret session fields (username,roles) remain inSharedPreferencesfor synchronous access. The first launch after upgrading from an older build runs a one-shot migration from the legacy plaintext keys into secure storage. - macOS keychain: on macOS the tokens live in the login keychain, because the data protection keychain needs an Apple Developer team and a provisioning profile, and the default macOS build is signed ad hoc without them.
The login keychain protects the tokens with an access control list that names the app, and keychain accessibility such as
first_unlock_this_devicedoes not apply there, so none is set on macOS. An ad-hoc signature names the app by its code hash, so after a rebuild macOS asks for the login keychain password before the app can read the stored session. Startup waits at most 10 seconds for the keychain in the session migration, then at most 10 seconds more in the session restore, and shows the login page if the password has not been entered by then; the stored session still opens once it is. With a team and a provisioning profile you can move to the data protection keychain: addkeychain-access-groupsto bothmacos/Runner/*.entitlementsfiles and setusesDataProtectionKeychain: trueandaccessibility: KeychainAccessibility.first_unlock_this_deviceinFlutterSecureStorageAdapter(lib/infrastructure/storage/secure_storage.dart); users then sign in once more.
- Create, update, delete, and list users
- Account profile view and update
- Change password screen
- Role-based routing for Admin and User roles
- Public and private route separation
- Protected admin-only pages
The template ships with a Sentry adapter (SentryAnalyticsService) and a conservative PII / token scrubber, but no DSN is committed — public templates with a committed DSN bleed events from every fork into the original project's quota. Provide a DSN at build time:
fvm flutter run --target lib/main/main_prod.dart \
--dart-define=SENTRY_DSN=https://YOUR_PUBLIC_KEY@oXXX.ingest.sentry.io/YOUR_PROJECT_IDfvm flutter build apk --release --target lib/main/main_prod.dart \
--dart-define=SENTRY_DSN=https://...- No DSN, or non-prod build:
AppDependencies.createAnalyticsService()returnsLogAnalyticsService. Errors land inAppLogger. No network egress. - DSN + prod build: bootstrap calls
SentryFlutter.init(...)and the analytics interface switches to the Sentry-backed implementation.SentryNavigatorObserverregisters automatically so route changes become breadcrumbs. Screen views add no breadcrumb of their own, so each navigation is recorded once.
sentryBeforeSend (see lib/infrastructure/analytics/sentry_scrub.dart) drops the following before any event leaves the device:
Authorization/Cookie/Set-Cookieheaders (case-insensitive)- The whole value of any other header whose name contains
password,otp,token,secret,authorizationor an api-key name (api-key,api_key,apikey), or is an OpenID Connect key, such asX-Auth-Token,Proxy-AuthorizationorX-Api-Key, replaced with[REDACTED] - The whole value of a tag named the same way as a credential body key, and email addresses, JWTs and the secrets
maskSecretsfinds in every other tag value - Email addresses, JWTs and the secrets
maskSecretsfinds in the user's id, username, email, IP address and name, and the user's data masked like a breadcrumb's data - Email addresses, JWTs and the secrets
maskSecretsfinds (see the last item) in every other request header value and everyrequest.envvalue; the header names are kept - The values of body keys whose names contain
password,otp,tokenorsecret(case-insensitive substring), at any depth, replaced with[REDACTED] - Email addresses and JWT-shaped strings (3 base64url segments) in exception values, the event message and the request body (which only an HTTP integration a fork adds, such as
sentry_dio, attaches), replaced with[REDACTED_EMAIL]/[REDACTED_JWT] - Email addresses and JWTs in the page location (
request.url, query string, fragment) and the transaction name, replaced with[REDACTED_EMAIL]/[REDACTED_JWT]. On web the OTP route/login-otp-verify/:emailputs the user's email there. - In the same text, the value of a JSON member or a
name=valuepair whose name contains one of those words, and the credentials of aBearerauthorization, replaced with[REDACTED]bymaskSecrets, whichAppLoggeralso runs over every line it prints.
sentryBeforeBreadcrumb, the beforeBreadcrumb hook, masks email addresses, JWTs and the secrets maskSecrets finds in the message and the data of every breadcrumb when it is recorded, and the value of every credential key of the data: the breadcrumbs of SentryAnalyticsService and the navigation breadcrumbs.
It also covers the http and ui breadcrumbs a fork adds through sentry_dio or SentryUserInteractionWidget.
installSentryScrubbers installs both hooks, and configureSentryOptions (lib/app/bootstrap/sentry_start.dart), the options AppBootstrap.run starts Sentry with, calls it.
It also turns print breadcrumbs off, so no printed line, and no AppLogger line, becomes a breadcrumb.
On web the SDK runs the app in a zone that made a breadcrumb of every printed line, and a scrubber cannot find a password or a code in free text.
Native builds never recorded AppLogger lines.
The native Sentry SDKs record breadcrumbs and send events of their own (activity and app lifecycle, system events, Java and Kotlin exceptions, ANR events, native crash reports), and none of them pass through the Dart hooks.
On Android the host app starts sentry-android itself (MainApplication.kt and NativeSentry.kt under android/app/src/main/kotlin) with beforeSend and beforeBreadcrumb hooks that mask them with a Kotlin copy of the same rules (NativeSentryScrubber.kt), and the Dart side turns autoInitializeNativeSdk off on Android only.
test/architecture/sentry_native_scrub_patterns_test.dart fails when the Kotlin patterns drift from the Dart ones, and the fixtures in test/fixtures/ run in the Dart tests and in the Android JVM unit tests (cd android && ./gradlew :app:testDebugUnitTest -Ptarget=lib/main/main_prod.dart).
Gradle needs JDK 17 there, and the Gradle wrapper is not committed: any Flutter Android build writes it, for example fvm flutter build apk --config-only --target lib/main/main_prod.dart.
The lifecycle breadcrumbs of sentry-android keep their state (such as resumed or foreground); a copy that Sentry merges into a Dart event still shows it masked.
On web the native SDK in the table is the Sentry JavaScript SDK, which sentry_flutter starts there.
| Platform | Dart events and breadcrumbs | Native breadcrumbs | Native events and crash reports |
|---|---|---|---|
| Android | Scrubbed | Scrubbed | Scrubbed |
| iOS | Scrubbed | Not scrubbed | Not scrubbed |
| macOS | Scrubbed | Not scrubbed | Not scrubbed |
| Linux, Windows | Scrubbed | None recorded | Minidumps, which cannot be scrubbed |
| Web | Scrubbed | None recorded | Not scrubbed |
Native scrubbing is not done on iOS and macOS yet: sentry_flutter starts sentry-cocoa there, and what it records itself leaves the device unmasked.
It is not done on web either, and the code fix for all three is tracked in #177.
On web sentry_flutter starts the JavaScript SDK with only its global handlers and its dedupe integration, so the JavaScript SDK records no console, fetch, XHR, DOM or navigation breadcrumbs of its own.
The global handlers send an event of their own for every uncaught JavaScript error and unhandled promise rejection, and so for a Dart error that escapes the zone SentryFlutter.init runs the app in, such as one thrown inside a JavaScript interop callback.
Such an event carries its error message and stack trace unmasked: the Dart beforeSend and beforeBreadcrumb hooks are not passed to the JavaScript SDK, and the beforeSend that sentry_flutter sets there only names the SDK.
It carries none of the Dart breadcrumbs, user or tags, because sentry_flutter does not copy the Dart scope to the JavaScript SDK on web.
The Dart events leave through the JavaScript SDK as well, but only after the Dart hooks have masked them.
When a DSN is set, sentry_flutter fetches the JavaScript SDK at start from https://browser.sentry-cdn.com (bundle.tracing.min.js in a release build, with a subresource integrity hash), so a fork with a strict Content Security Policy allows that origin in script-src and the ingest host of its DSN in connect-src.
Where Trusted Types are enforced, the loader creates a policy named sentry-dart, which the trusted-types directive must allow.
On Linux and Windows sentry-native records no breadcrumbs of its own, but a native crash uploads a minidump, a copy of process memory that no text scrubber can clean; set options.enableNativeCrashHandling = false for those platforms in configureSentryOptions if that is not acceptable.
Sentry's server-side data scrubbing (Advanced Data Scrubbing in the project settings) is a useful complement on every platform, but it runs after the data has left the device, so it does not replace the hooks.
On Android, Gradle passes the DSN to the native SDK only for a lib/main/main_prod.dart build (android/app/build.gradle.kts), the only entry point that selects the prod environment in which the Dart side starts Sentry.
A fork that renames that entry point changes the check there too; when the two disagree, or the host app does not answer the check, the app throws a StateError at start in a debug or profile build, and a release build logs the error and runs exactly as without a DSN: no Sentry SDK and the log-only analytics.
A native option set on the Dart side in configureSentryOptions does not reach the Android SDK, so set it in NativeSentry.kt as well; Session Replay would need its recorder wired there before it can be turned on.
The scrubber is unit-tested independently of the SDK; review it against your fork's PII surface before adopting in production. Adding new redaction rules is a single-file change.
Default sample rate: tracesSampleRate: 0.2. Adjust in configureSentryOptions if your event budget needs different cadence.
The HTTP client supports certificate pinning to defend against MITM via user-installed root CAs, rogue intermediates, and other forms of device-trust-store compromise. Default off — the template ships with an empty pin list because pinning the wrong cert bricks every install.
How it works:
AppConfig.certificatePins(lib/infrastructure/config/environment.dart) is aList<String>of base64 SHA-256 hashes. Empty list = pinning disabled (use system trust); non-empty list installs a custom Dio adapter that usesSecurityContext(withTrustedRoots: false)so every certificate falls throughbadCertificateCallbackfor pin checking. This is the only correct way to enforce pinning even against system-trusted but adversarial CAs.- A pin mismatch fails the TLS handshake with a
CertificatePinningException(lib/infrastructure/http/certificate_pinner.dart), so dio reportsDioExceptionType.unknownwith that exception as itserror, notDioExceptionType.badCertificate(dio raises that only forIOHttpClientAdapter.validateCertificate).ResilienceInterceptorneither retries it nor counts it toward opening the endpoint's circuit.CacheInterceptornever answers it from cache: the connection may be intercepted, so the request fails instead of showing earlier data. Retries and the token-refresh call go through the same pinned adapter as the original request.ApiClientthrows it as anUntrustedCertificateException(lib/core/errors/app_api_exception.dart), aFetchDataExceptionwhose message names the host and port for the log.LoginRepository,AccountRepository,UserRepository,AuthorityRepositoryImpl,DynamicFormRepositoryandLifecycleRepositoryreport it as anAppErrorwith theAppErrorCode.networkCertInvalidkey. The sign-in, OTP, registration, password reset, change password and account screens, the user list and editor, and the dynamic form pages show its translated message instead of the exception text. The roles field of the user list and editor still says "Roles unavailable" without the reason. - No other TLS failure is answered from cache either, pinning or not: a certificate the device does not trust (expired, self-signed, a wrong device clock, a captive portal or an intercepting proxy), a hostname mismatch, or a handshake closed before it completed ("Connection terminated during handshake") fails the request.
A dropped network can close a handshake too, but so can anything between the device and the server, so that case fails closed as well.
A handshake reset before it completed is a connection error instead, which the cache answers like a lost connection.
CacheInterceptoranswers a GET from cache only when it cannot reach the server: a connection error, a timeout, or a connection closed or reset before the whole response arrived; a response that dart:io cannot parse counts too.ApiClientthrows such a failure, and a certificate thatIOHttpClientAdapter.validateCertificaterejects (DioExceptionType.badCertificate), as anUntrustedCertificateExceptiontoo, so the screens above show the same translated message; the exception message keeps the TLS error for the log.ResilienceInterceptordoes not count it toward opening the endpoint's circuit, so a circuit-open error never replaces it. On web the browser reports a TLS failure as a connection error, so there it cannot be told apart from a lost connection. - Web platform is a hard no-op. Browsers control TLS; from JS we cannot intercept the handshake. Use HSTS / CT pinning at the server side instead.
Extract pins from your live backend:
openssl s_client -servername api.example.com -connect api.example.com:443 < /dev/null 2>/dev/null \
| openssl x509 -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64(v1 caveat — full-cert hash, not SPKI. The OWASP-recommended pin is SHA-256(SubjectPublicKeyInfo), which survives certificate rotation as long as the keypair is reused. Extracting SPKI from X.509 DER requires ASN.1 parsing; we ship full-cert hash for simplicity and auditability. Stored pin shape stays identical when upgrading — only the hash input changes. Track this gap in your fork before adopting in production.)
Key rotation procedure:
- Add the new (backup) pin to
certificatePinsbefore rotating server keys. Ship that app version. - Rotate server certs to the new keypair.
- In a later release, remove the old pin.
This avoids a window where in-flight users with the old app version cannot reach the new cert.
A build with AUTH_PROVIDER=oidc replaces the password sign-in with a single "Sign in with SSO" button that signs the user in through an OpenID Connect provider.
The app is a public client: it uses the Authorization Code flow with PKCE (S256), keeps no client secret, holds the provider's tokens in secure storage, and sends the provider's access token to the backend as the bearer.
Without AUTH_PROVIDER, or with AUTH_PROVIDER=jwt, the app behaves exactly as before and never calls the OIDC code.
fvm flutter run --target lib/main/main_prod.dart \
--dart-define=AUTH_PROVIDER=oidc \
--dart-define=OIDC_ISSUER=https://id.example.com/realms/acme \
--dart-define=OIDC_CLIENT_ID=acme-app \
--dart-define=OIDC_ROLE_CLAIM=realm_access.roles- Every platform: Android through a Custom Tab, iOS and macOS through
ASWebAuthenticationSession, web in the same tab throughweb/redirect.html, and Linux and Windows through the system browser with a loopback redirect. - Roles come from a claim of the ID token (
OIDC_ROLE_CLAIM,OIDC_ADMIN_ROLES,OIDC_USER_ROLES) and map toROLE_ADMINandROLE_USER. - Refresh goes to the provider's token endpoint through the same
TokenRefreshInterceptor, and a logout the user starts also ends the session at the provider. - Registration, forgot password, OTP sign-in and change password belong to the provider, so their routes and entry points are left out.
Try it locally with the bundled Keycloak demo, which needs no backend because main_local.dart answers API calls from the mock data:
docker compose -f tool/oidc-demo/docker-compose.yml up -d --wait
fvm flutter run -d chrome --web-port 8081 --target lib/main/main_local.dart --dart-define-from-file=tool/oidc-demo/oidc-demo.envSign in as admin / admin or user / user, and stop the demo with docker compose -f tool/oidc-demo/docker-compose.yml down -v.
docs/oidc.md has the provider setup for Keycloak, Zitadel, Auth0 and Entra ID, the configuration reference, the backend contract with Next.js and Spring Boot examples, troubleshooting and the known limitations.
IdleTimeoutObserver (lib/core/security/) signs the user out after a configurable window of inactivity. Default threshold is 15 minutes in production, disabled in dev/test (where hot-reload + mocked sessions would make a 15-minute kick out hostile).
- Configured via
AppConfig.idleTimeoutinlib/infrastructure/config/environment.dart:AppConfig.prod()sets 15 minutes,AppConfig.dev()andAppConfig.test()setnull, which disables it. - Background-aware: on iOS the Dart
Timerpauses while the app is suspended, so the observer also captures wall-clockDateTime.now()on lifecycle transitions; resumes past the threshold fire immediately. - Browser tabs: on web all tabs of the app share one session, and an idle timeout in any tab signs out every tab.
So each tab records its latest activity in
localStoragewhile it is in the foreground, at most once every 10 seconds, and before a tab signs out, from its timer or when the user returns to it, it checks the latest activity of every tab. Returning to a tab left idle does not sign out the session the user is working in, in another tab. Activity can reach the other tabs up to 10 seconds late, so the session can end up to 10 seconds early. - Surfacing: when the timer fires, the user sees a localized snackbar ("You were signed out due to inactivity.") and the router redirects to login. The reason is tagged
SessionExpiredReason.idleTimeoutso logs and UI can distinguish it from a manual sign-out. - Activity scope: a touch, click or drag, mouse wheel and trackpad scrolling, and key presses reset the timer, so a user who only scrolls a long page or only types in a form stays signed in. Moving the mouse without a button pressed does not count, and neither do key events Flutter synthesizes to match the keyboard state.
Compliance baselines that expect inactivity timeouts: SOC2 CC6.1, ISO 27001 A.9.4.2, GDPR Art. 32.
At startup the app reads GET /app/config, a public endpoint that needs no session (mock: assets/mock/GET_app_config.json).
LifecycleGate (lib/features/lifecycle/) sits in the MaterialApp builder, around the session restore and the router, and shows a full-screen page in their place when the config asks for one.
maintenanceMode: trueshows the maintenance screen with the config'smaintenanceMessage(or a translated default), themaintenanceEstimatedEndtime in the user's language and time zone, and a retry button.- A
minimumVersionabove the installed version shows the update screen, which opens thestoreUrlwhen it is an https link. - The app checks again when it comes back to the foreground, at most once every 5 minutes (
LifecycleBloc.defaultRecheckInterval). - Fail open: when the endpoint is missing or the request fails, the app opens normally and the failure is logged as an error.
An untrusted certificate is reported with
AppErrorCode.networkCertInvalid. A failed re-check keeps a maintenance or update screen the server already asked for, so a server error or a lost connection does not let the user skip a required update. - A non-empty
featureFlagsmap in the config replaces the flags inFeatureFlagService.
ResilienceInterceptor retries a transient failure (timeout, connection error, 502/503/504) only when the request is safe to send twice.
GET, HEAD, OPTIONS, PUT and DELETE are idempotent by HTTP semantics and are retried.
A POST or PATCH may already have been processed by the server, so it is retried only when it carries an Idempotency-Key header; without one, the caller gets the error.
The template ships an opt-in IdempotencyInterceptor that attaches an Idempotency-Key: <uuid-v4> header so a properly configured backend can deduplicate.
The header is a contract, not a guarantee.
The server must be configured to deduplicate by this header (see Stripe's worked example, IETF draft idempotency-key-header).
If the backend ignores it, opting in is worse than leaving it off: the key is what lets ResilienceInterceptor retry a POST or PATCH, and every retry the server processes creates another record.
Confirm support before opting in production call sites.
Opt in per call:
await apiClient.post<User>('/admin/users', user, idempotent: true);
await apiClient.put<User>('/admin/users', user, pathParams: '42', idempotent: true);
await apiClient.patch<User>('/admin/users', user, pathParams: '42', idempotent: true);Worked example: UserRepository.create opts in (see lib/features/users/data/repositories/user_repository.dart).
Retry stability: once generated, the key is stashed on RequestOptions.extra['_idempotency_key'] and reused on every subsequent pass — covers both ResilienceInterceptor retries (transient 5xx) and TokenRefreshInterceptor re-submission after a 401 + refresh.
GET, HEAD, OPTIONS, DELETE never get the header (safe / idempotent by HTTP semantics).
CacheInterceptor stores successful GET responses and answers from them by the request's CachePolicy (options.extra['cachePolicy'], networkFirst by default):
| Policy | Behavior |
|---|---|
networkFirst |
Sends the request; answers from the cache only when the server cannot be reached |
cacheFirst |
Answers from a fresh entry without sending the request; otherwise behaves as networkFirst |
cacheOnly |
Answers from a fresh entry or fails with "No cached data available"; it never reaches the network, so neither being offline nor an open circuit stops it |
networkOnly |
Never reads or writes the cache |
An entry is fresh for its TTL, 5 minutes unless options.extra['cacheTtl'] sets another.
Past its TTL an entry still answers a GET that cannot reach the server, with an x-cache: stale header, until it is 24 hours old (CacheEntry.maxStaleAge).
An entry read after that is deleted instead.
Ending the session, by a logout, the idle timeout or a token that cannot be refreshed, deletes every entry, and a clear that leaves some behind is logged as an error.
The response of a GET still on its way when the session ends is not stored, so it cannot answer the next user with the previous user's data.
The entries live in SharedPreferences, which on web is localStorage, whose per-origin quota the stored session and the preferences share.
So SharedPrefsCacheStorage evicts: when it starts and before each write it deletes the entries past 24 hours, and before each write it deletes the oldest entries until the new one fits in 200 entries and 1,000,000 characters.
A write the store refuses, such as one past the quota, is logged as an error with the key and the size, the entry is not cached, and the GET still succeeds.
A GET cannot reach the server when the device is offline, or when the connection is refused, times out, or is closed or reset before the whole response arrived.
A response that dart:io cannot parse, such as one from a server that does not answer in HTTP, counts the same way, since dart:io reports it like a connection reset while the response is awaited.
Every other failure reaches the caller, a server error or a TLS failure included: an untrusted certificate is never answered from the cache (see Certificate Pinning).
So does an open circuit: after repeated failures ResilienceInterceptor refuses the requests to that endpoint for 30 seconds, and a GET fails with CircuitBreakerOpenException even when a response is cached.
An answer from a fresh cacheFirst or cacheOnly entry carries x-cache: hit.
Mechanism for sensitive screens (credentials, OTP, payment, tokens) shipped disabled by default. The template's job is to provide the hook, not impose UX on every fork — many apps legitimately want users to screenshot QR codes, receipts, etc.
- Helper:
ScreenCaptureProtection.enable()/.disable()inlib/core/security/screen_capture_protection.dart. - Per-screen mixin:
ScreenCaptureProtected<T>inlib/core/security/screen_capture_protected.dart— add to aState<T>and it auto-enables oninitState, releases ondispose.
Platform behaviour (asymmetric — read this):
| Platform | What happens |
|---|---|
| Android | FLAG_SECURE: screenshots and screen recording are blocked; recent-apps preview renders black. |
| iOS | iOS provides no API to block screenshots (Apple policy). The task-switcher snapshot is blurred so on-screen secrets do not leak into the app preview. Screenshots while foregrounded still succeed. |
| Web / Desktop | No-op. |
Opt-in example (uncomment in LoginScreen):
import 'package:flutter_bloc_advance/core/security/screen_capture_protected.dart';
class _LoginScreenState extends State<LoginScreen>
with ScreenCaptureProtected<LoginScreen>, SingleTickerProviderStateMixin {
// ...
}When to enable: credential entry, OTP screens, token display. When not to enable: screens with content the user is expected to capture (QR codes, receipts).
- 16 supported field types: text, email, password, number, phone, textarea, dropdown, multiSelect, date, datetime, toggle, checkbox, radio, slider, sectionHeader, divider
- Schema-only loading (
/dynamic-forms/sample— CRM lead demo) or bundled{schema, values}loading with per-instancepathParams(/user/:id/extended-info— user extended-info kitchen-sink covering all 16 types) - Reusable from any feature via
DynamicFormsFeatureRoutes.withBloc(context, child)— the engine lives undershared/dynamic_forms/and is feature-agnostic
- Dark and light themes
- Responsive layout support
- English and Turkish localization
- Design system foundation with reusable components
- Multi-platform support for Android, iOS, Web, macOS, Linux, and Windows
- BLoC for state management
- Feature-First Clean Architecture with domain layer (entities + repository interfaces)
- Use Cases for business logic isolation
- Repository pattern with
Result<T>sealed type (no raw exceptions) - Typed error hierarchy (
NetworkError,AuthError,ValidationError,ServerError, etc.) - Dio HTTP client with interceptor chain (auth, logging, mock)
- Feature-based routing with clean boundaries
- Manual JSON serialization for models
- Environment-driven configuration for local and production modes
- Architecture guard tests enforcing dependency rules
- Flutter
3.47.7and Dart3.13.5 - FVM recommended for version consistency
- Android SDK for Android builds
- Xcode for iOS and macOS builds
- Minimum deployment targets: Android 8.0 (API 26), iOS 15, macOS 12
# macOS / Linux
brew tap leoafarias/fvm
brew install fvm
# Windows
choco install fvmgit clone https://github.com/cevheri/flutter-bloc-advanced.git
cd flutter-bloc-advanced
fvm install 3.47.7
fvm use 3.47.7
fvm flutter pub getAll local requests use assets/mock/, so you can explore the app without standing up a backend first.
# Mobile
fvm flutter run --target lib/main/main_local.dart
# Web
fvm flutter run -d chrome --target lib/main/main_local.dart
# Web with a specific port
fvm flutter run -d chrome --web-port 3000 --target lib/main/main_local.dart| Role | Username | Password | Access |
|---|---|---|---|
| Admin | admin |
admin |
All pages |
| User | user |
user |
Own profile and settings |
# Mobile
fvm flutter run --target lib/main/main_prod.dart
# Web
fvm flutter run -d chrome --target lib/main/main_prod.dartThe production environment is configured in lib/infrastructure/config/environment.dart.
| Category | Technology |
|---|---|
| Flutter | 3.47.7 |
| Dart | 3.13.5 |
| State Management | flutter_bloc 9.1.1, bloc_concurrency 0.3.0, stream_transform 2.1.2 |
| Routing | go_router 18.1.1 |
| HTTP | dio 5.11.1 (interceptor chain: connectivity, auth, token-refresh, idempotency, resilience, cache, dev-console, logging, mock (dev/test only)) |
| Forms | flutter_form_builder 10.3.0+2 |
| Localization | intl 0.20.3, intl_utils 2.8.17 |
| Storage | shared_preferences 2.5.6, flutter_secure_storage 11.2.0 |
| Testing | flutter_test, bloc_test, mocktail |
lib/
app/ # Application foundation
di/ # Dependency injection (manual, no get_it)
router/ # Centralized routing with go_router
shell/ # Responsive shell (sidebar, top bar, bottom nav, command palette)
theme/ # Theme management
core/ # Zero-dependency foundation
errors/ # AppError sealed hierarchy + API exceptions
result/ # Result<T> sealed type (Success / Failure)
logging/ # Structured logging (AppLogger)
security/ # JWT utilities
features/ # Feature-based clean architecture modules
<feature>/
application/ # BLoCs, Use Cases
data/ # Models, Repositories (concrete implementations)
domain/ # Entities, Repository interfaces
navigation/ # Feature-specific routes
presentation/ # Pages and feature-specific widgets
infrastructure/ # Cross-cutting technical concerns
config/ # Environment, TemplateConfig
http/ # Dio ApiClient + interceptors (auth, logging, mock)
storage/ # Local storage (SharedPreferences)
shared/ # Shared foundation across features
design_system/ # Theme, typography, color tokens, 13 components
dynamic_forms/ # Server-driven form engine (16 field types, bundle load, public withBloc helper)
models/ # Cross-feature entities (UserEntity)
widgets/ # Reusable components (buttons, forms, dialogs)
generated/ # Localization generated files (do not edit)
l10n/ # ARB translation files
main/ # Entry points (main_local.dart, main_prod.dart)
test/ # Mirrors lib/ structure
architecture/ # Import guard tests (dependency rule enforcement)
features/ # Feature tests (application, data, presentation)
mocks/ # Mock classes and fake data
All template placeholders use __KEYWORD__ naming. Run a global search for __ across the project, replace each keyword with your own value, and you are done.
| Keyword | Description | Example | Where Used |
|---|---|---|---|
__APP_NAME__ |
Full application name | Acme Dashboard |
template_config.dart, manifest.json, 404.html |
__APP_SHORT_NAME__ |
Short name (PWA, mobile) | Acme |
manifest.json |
__APP_DESCRIPTION__ |
One-line app description | Team collaboration platform |
manifest.json |
__PROD_API_URL__ |
Production API endpoint | https://api.acme.com/v1 |
template_config.dart, index.html |
__WEB_BASE_URL__ |
Deployment URL (trailing /) |
https://app.acme.com/ |
index.html, sitemap.xml, robots.txt, .well-known/security.txt |
__WEB_BASE_PATH__ |
Path of the deployment URL (leading and trailing /) |
/ |
404.html, robots.txt |
__GITHUB_REPO_URL__ |
GitHub repository URL (no trailing /) |
https://github.com/acme/dashboard |
template_config.dart, .well-known/security.txt |
__DOCS_URL__ |
Documentation website | https://docs.acme.com |
template_config.dart |
__SPONSOR_URL__ |
Sponsor page | https://github.com/sponsors/acme |
template_config.dart |
__SECURITY_CONTACT__ |
Email address for vulnerability reports | security@acme.com |
.well-known/security.txt |
__AUTHOR_NAME__ |
Developer or company name | Acme Inc. |
index.html, humans.txt |
__AUTHOR_URL__ |
Author website / profile | https://github.com/acme |
index.html, humans.txt |
__AUTHOR_LOCATION__ |
Author location | San Francisco, CA |
humans.txt |
The hosted demo fills these placeholders only at deploy time, from .github/pages/demo-values.env, and scripts/check_web_placeholders.sh fails the GitHub Pages build of any repository that would still publish one, naming the file and keyword (__PROD_API_URL__ is exempt because the mock-mode web build does not use it).
template_config.dart reads __GITHUB_REPO_URL__, __DOCS_URL__ and __SPONSOR_URL__ at build time, so you can replace them there or give them to flutter run and flutter build.
Pass --dart-define=GITHUB_REPO_URL=https://github.com/acme/dashboard for one value, or --dart-define-from-file=values.env for a file of KEY=value lines; the hosted demo builds with .github/pages/demo-values.env.
The login page shows its community section only when the repository URL is given, and its Sponsor and Documentation links only when their URLs are; the sidebar and settings show the Website link only when the docs URL is given.
An unfilled template therefore shows no link that leads nowhere.
An empty value, such as --dart-define=SPONSOR_URL=, leaves that link out.
A web build that still contains one of these placeholders in main.dart.js fails scripts/check_web_placeholders.sh.
Also update manually:
pubspec.yaml: Package name, version, description.web/index.html: Page<title>, meta descriptions, Open Graph / Twitter Card text.lib/l10n/intl_en.arb/intl_tr.arb: App-specific translations, includingapp_title, the app name the task switcher and the browser tab show once the app has started (keep it in step with the<title>inweb/index.html).
These platform identifiers still name the template and its author, so rename them by hand:
- Android:
namespaceandapplicationIdinandroid/app/build.gradle.kts, the package ofMainActivity.ktand its folder underandroid/app/src/main/kotlin/, andandroid:labelinandroid/app/src/main/AndroidManifest.xml. - iOS:
PRODUCT_BUNDLE_IDENTIFIERinios/Runner.xcodeproj/project.pbxproj, andCFBundleDisplayNameandCFBundleNameinios/Runner/Info.plist. - macOS:
PRODUCT_NAME,PRODUCT_BUNDLE_IDENTIFIERandPRODUCT_COPYRIGHTinmacos/Runner/Configs/AppInfo.xcconfig, and the test bundle identifiers inmacos/Runner.xcodeproj/project.pbxproj. - Linux:
BINARY_NAMEandAPPLICATION_IDinlinux/CMakeLists.txt, and the window title inlinux/my_application.cc. - Windows:
BINARY_NAMEinwindows/CMakeLists.txt, the version strings inwindows/runner/Runner.rc, and the window title inwindows/runner/main.cpp. - Issue templates: the Discussions links in
.github/ISSUE_TEMPLATE/config.ymland.github/ISSUE_TEMPLATE/tech_debt.yml. - SonarCloud:
sonar.projectKeyandsonar.organizationinsonar-project.propertiesandscripts/run_sonar_scanner.sh. - GitHub Pages:
--base-hrefin.github/workflows/build-web.ymlandscripts/deploy_web.sh, the same path as__WEB_BASE_PATH__. - Single sign-on: the redirect scheme
com.example.flutterblocadvanceinOidcSettings.defaultRedirectScheme(lib/infrastructure/config/auth_config.dart) and inmanifestPlaceholders["oidcRedirectScheme"](android/app/build.gradle.kts), which must stay equal, and in the demo realm oftool/oidc-demo/docker-compose.yml. The URIs registered at your provider change with it; see docs/oidc.md.
lib/infrastructure/config/template_config.dart holds the template values the Dart code reads: the app name shown in the developer console, the production API URL, and the repository, docs and sponsor links.
# Android APK
fvm flutter build apk --target lib/main/main_prod.dart
# iOS
fvm flutter build ios --target lib/main/main_prod.dart
# Web
fvm flutter build web --target lib/main/main_prod.dart# Run all tests
fvm flutter test
# With coverage
fvm flutter test --coverage
# Useful for debugging order-dependent issues
fvm flutter test --concurrency=1 --test-randomize-ordering-seed=random
# Run only widget tests / everything except widget tests (fast unit loop)
fvm flutter test --tags widget
fvm flutter test --exclude-tags widgetTags are declared in dart_test.yaml (widget, golden; integration is
reserved for a possible future on-device suite). The end-to-end smoke
(test/integration/app_smoke_test.dart) boots the whole app in mock mode and
drives login → dashboard; it runs headless as part of the normal flutter test.
# Golden / visual-regression tests (alchemist, CI/Ahem-rendered)
fvm flutter test --tags golden # run goldens
fvm flutter test --tags golden --update-goldens # regenerate after intentional UI changesGolden tests live next to the code they cover (feature-first, mirroring lib/;
images in a sibling goldens/ci/ dir) and use alchemist's CI (Ahem-rendered)
variant so they're identical across macOS/Linux/CI. Regenerate with
--update-goldens after an intentional visual change.
See docs/testing-architecture.md for the test
structure, global bootstrap, and how to write tests for each layer.
fvm dart analyze
fvm dart fix --apply
fvm dart format . --line-length=120Install pre-commit (format + analyze) and pre-push (test) hooks:
bash scripts/setup_hooks.sh| Layer | What is Tested |
|---|---|
| Use Cases | Business logic delegation, input routing |
| Models / Entities | fromJson, toJson, equality, copyWith |
| Mappers | Entity ↔ Model transformations |
| Repositories | API calls, Result type returns, error mapping |
| BLoCs | State transitions, event handling, error states |
| Screens | Widget rendering, user interactions, navigation |
| Architecture | Import guard tests enforcing dependency rules |
GitHub Actions workflows included in this repository:
build_and_test.ymlfor build and test automationbuild-web.ymlfor web buildssonar_scanner.ymlfor SonarQube analysis
To enable SonarQube, add the SONAR_TOKEN secret to your repository or organization.
| Component | Version |
|---|---|
| Gradle | 9.3.1 |
| Android Gradle Plugin | 9.1.0 |
| Kotlin | 2.4.0 (built-in Kotlin, android.builtInKotlin=true) |
| Java Compatibility | 17 |
| NDK | Dynamic (flutter.ndkVersion) |
| Min SDK | 26 (required by sentry_flutter 10) |
| Build Config | Kotlin DSL (.gradle.kts) |
- Create a new feature folder under
lib/features/<feature>/. - Define domain entities in
domain/entities/and repository interface indomain/repositories/. - Implement data models in
data/models/and repository indata/repositories/(returnResult<T>). - Add use cases in
application/usecases/(one class per operation). - Create BLoC(s) in
application/usingswitch (result) { case Success: ... case Failure: ... }. - Implement the UI in
presentation/pages/. - Define feature routes in
navigation/. - Register the feature routes in
lib/app/router/app_router.dart. - Register DI in
lib/app/di/app_dependencies.dart(repository) andapp_scope.dart(BLoC). - Add tests in
test/features/<feature>/mirroring the feature structure. - Add each new repository method that sends requests through
ApiClientto the table intest/architecture/mock_asset_coverage_test.dart, with the mock files it needs underassets/mock/.
For a CRUD feature, fvm dart run tool/generate_feature.dart <feature> does steps 1 to 7 and 10, writes the mock data, prints the code for steps 8 and 9, and names the methods to add in step 11.
Dependency rules (enforced by architecture guard tests):
core/imports nothing from the projectshared/imports only fromcore/features/imports fromshared/,infrastructure/,core/— never from other featuresapp/can import from all layers
Community contributions are welcome. If you want to improve the template, add features, polish documentation, or refine the UI, feel free to open an issue or submit a pull request.
- Fork the repository.
- Create a feature branch from your fork.
- Make your changes with tests or documentation updates when relevant.
- Run
fvm dart analyzeandfvm flutter test. - Open a pull request with a clear summary of the change.
If you are unsure where to start, documentation improvements, screenshot refreshes, and test coverage enhancements are all valuable contributions.
| Document | Description |
|---|---|
| Transformation Log | What changed, why, before/after comparison |
| Architecture Migration | Feature-first migration guide |
| Feature-First Boundaries | Clean architecture design document |
| Upgrade Guide — Flutter 3.44.0 | Flutter 3.41.8 → 3.44.0 upgrade notes (v0.21.0) |
| Upgrade Guide - Flutter 3.47.7 | Flutter 3.44.3 → 3.47.7 upgrade notes |
| Single Sign-On (OIDC) | AUTH_PROVIDER=oidc setup per provider, the Keycloak demo, the backend contract and the internals |
- Understanding Flutter BLoC: A Comprehensive Guide
- Flutter Documentation
- BLoC Library
- flutter_bloc on pub.dev
- go_router on pub.dev
- dio on pub.dev
This project is licensed under the MIT License. See the LICENSE file for details.




