A production-ready Flutter template built with the latest packages and best practices, supporting Flutter 3.47.4 and above. It provides a robust foundation for building scalable applications.
- 🎯 Riverpod 3 with code generation
- 🔒 Built-in authentication pack with secure storage (Hive CE + AES-256), token refresh and backend-driven permissions
- 🌐 Type-safe API integration with retrofit clients on one shared Dio
- 🌍 Internationalization with Flutter's gen-l10n (ARB files, English and Turkish)
- 💾 Secure local storage with Hive CE
- 🧩 Optional Modules: Firebase (Analytics, Remote Config) and Sentry, each removable in a few lines
- 🧪 Pre-configured unit and widget tests, plus a Widgetbook catalog
- 🎨 A Designer workflow in the Catalog: the Designer's Claude edits screens and Shared widgets and hot-reloads them live, and every PR can get its own hosted Catalog for review
- ⚡ Modern navigation with GoRouter 18
- 🚀 CI on every PR, and a tag-driven release workflow with fastlane
- 🛠️ Custom linting and devtools configuration
- 🤖 Claude Code rules: a short root
CLAUDE.md, path-scoped rules per area and atest-writeragent
Core Libraries:
- State Management: Riverpod 3, Freezed 4 (immutable state)
- Network Layer: Dio 5 + retrofit, with a sealed
ApiExceptionand translated error messages - Local Storage: Hive CE 2 with AES-256 encryption
- UI & Navigation: GoRouter 18, FlexColorScheme 9, Google Fonts 9, Material 3
Developer Tools:
- Flutter Lints 6, riverpod_lint and the template's own analyzer plugin (
packages/app_lints) - Custom linting rules (
lint_rules.yaml) - Build Runner, code generation
- Melos 7 scripts across the workspace
- Dart & Flutter DevTools support
The repo is a Dart pub workspace: the app lives in app/, shared infrastructure in packages/core/, and Melos scripts at the root run across every package.
.
├── app/ # The app: pubspec, lib/, test/, assets/, platform folders, fastlane lanes
├── packages/
│ ├── core/ # Infrastructure every app gets (bootstrap, network, session, permissions, logging)
│ ├── firebase_module/ # Optional Module: Firebase Analytics and Remote Config
│ ├── sentry_module/ # Optional Module: Sentry error reporting
│ └── app_lints/ # Analyzer plugin: the template's architecture rules and your app's own
├── widgetbook/ # Widgetbook catalog (web)
├── .github/ # Workflows, the shared setup action, Dependabot
├── Gemfile # fastlane, for the release workflow
└── pubspec.yaml # Workspace list and Melos scripts
app/lib/
├── app/ # App-wide setup: config, theme, tokens, Modules, overrides, l10n delegates
├── constants/ # App-wide constants (assets, strings)
├── data/ # Shared models
├── fakes/ # FakeStorage and FakeScope, for tests and the catalog
├── features/ # Features (authentication, home, demo)
│ └── authentication/
│ ├── data/
│ ├── domain/ # Freezed request/response models only
│ ├── fakes/ # The Feature's Fakes (FakeAuthRepository)
│ └── presentation/
├── hive/ # Local storage setup and adapters
├── l10n/ # ARB files and generated localizations
├── router/ # Navigation, routing and the permission menu
├── shared/ # Code shared between Features (permission keys)
│ └── widgets/ # Shared widgets several Features use
├── utils/ # Utility functions
├── main_<flavor>.dart # One entry point per flavor (main.dart is dev)
└── my_app.dart # App configuration
Start from everything, then remove what you don't need. Each step links to the section with the details.
- Use this template. Click Use this template on this repo (or on your organization's copy, see below). The new repo gets its own fresh history and never merges template updates; later template fixes reach it by hand.
- Set your identity. Edit the ids and names listed in Customize names and IDs:
app/android/gradle.properties,app/Identity.xcconfig,app/web/manifest.json,app/web/index.html,app/windows/CMakeLists.txtandapp/linux/CMakeLists.txt. The Dart package name and the Kotlin namespace stay as they are. - Point at your backend. Set
apiBaseUrlandapiKeyper flavor inapp/lib/app/config.dart, then replace the demo login (reqres.in) and the demo permission loader (app/lib/features/authentication/data/demo_permissions.dart) with your own. - Design context. Fill in
PRODUCT.md(or run/impeccable teachwith the impeccable plugin on) and rewrite the prose ofDESIGN.mdfor your app. After changing tokens inapp/lib/app/tokens.dart, rundart run melos run design:update:DESIGN.md's frontmatter is generated from the theme and CI fails when it is out of date. - Register the ids. Register the bundle ids with Apple (Certificates, Identifiers & Profiles;
.devand.stagingtoo if you run those flavors on devices) and create the app in Play Console. For local device builds, add your Apple team id as in Signing on iOS and macOS. - Firebase. Edit and run the
firebase:configure:<flavor>scripts as in Set up Firebase, or remove the Firebase Module. - Sentry. Paste your DSN as in Sentry Module, or remove the Sentry Module.
- Protect
main. In Settings > Rules > Rulesets (or Settings > Branches), protectmain, require one approval before merging (one developer's review; no CODEOWNERS file, see Review) and require the status checkscheck,build-android,build-iosandbuild-webfromci.yaml. "Use this template" doesn't copy this setting, and the checks only appear in the picker afterci.yamlhas run once. See CI. - fastlane placeholders. Replace the certificates repo URL in app/ios/fastlane/Matchfile (or set
MATCH_GIT_URL); for local runs, alsoteam_idin app/ios/fastlane/Appfile andjson_key_filein app/android/fastlane/Appfile. The lanes refuse to upload while the id is stillcom.example.temp. - Release secrets, variables and match. Follow One-time setup: the Play upload keystore and service account, the App Store Connect API key, the first
matchrun that creates the certificate and profile, the secrets and variables, thenRELEASE_ENABLED. - Widgetbook hosting. Two optional opt-ins, independent of each other: publish
main's catalog to Pages (the Pages source and theWIDGETBOOK_PUBLISHvariable, as in Publish it to GitHub Pages), and host a preview for every PR (a Firebase project, its service-account key and theWIDGETBOOK_PR_PREVIEWvariable, as in Preview every PR). Or remove Widgetbook. - Runners (optional). Set the repository variables
LINUX_RUNNER,MACOS_RUNNERandWINDOWS_RUNNERto use other runner labels. See CI. - Your own Claude Code rules (optional). Add them as
.claude/rules/app-*.md, or edit the template's rules freely: your app never merges template updates. See Claude Code. - Clean up. Delete the open-source files a private app doesn't need:
CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,LICENSE,.github/FUNDING.ymland.github/ISSUE_TEMPLATE/.
If your organization keeps its own copy of this repo (to add its own rules or Modules and merge updates from here), mark that copy as a template repository: Settings > General > Template repository. Without it, the copy has no Use this template button.
Put your company's Claude Code rules in .claude/rules/company-*.md and leave the template's own files untouched, so updates from here merge without conflicts. See Claude Code.
-
Install dependencies (every package):
flutter pub get
-
Generate code (every package):
dart run melos run gen
-
Run the app (dev flavor):
cd app flutter run --flavor dev -t lib/main_dev.dart -
Analyze and test, as CI does:
dart run melos run analyze dart run melos run test
This template ships with dev, staging, and prod flavors for Android and iOS. On web and desktop the entry point alone picks the flavor.
Run these from app/, or use the dev/staging/prod configs in .vscode/launch.json.
- Dev:
flutter run --flavor dev -t lib/main_dev.dart
- Staging:
flutter run --flavor staging -t lib/main_staging.dart
- Prod:
flutter run --flavor prod -t lib/main_prod.dart
- Android application id, flavor suffixes and app names: the
app.*keys in app/android/gradle.properties (the Kotlin namespace stays as it is) - Web app name:
namein app/web/manifest.json. The description is in two places that can't share one value:descriptioninmanifest.jsonand thedescriptionmeta tag in app/web/index.html. - Windows app name:
APP_NAMEin app/windows/CMakeLists.txt - Linux application id and window title:
APPLICATION_IDandAPP_NAMEin app/linux/CMakeLists.txt - Windows and Linux executable file name (
temp.exe,temp):BINARY_NAMEin the same two files. The Flutter tool reads it from that line, so it can't come from another value; change it only if the file name matters to you. - iOS and macOS bundle id, display name and the macOS copyright (About panel): app/Identity.xcconfig, included by every flavor xcconfig. The iOS dev and staging flavors add their suffixes (
.dev,Dev) inapp/ios/Flutter/<Config>-<flavor>.xcconfig. - Per-flavor backend URL and settings: app/lib/app/config.dart. A plain
flutter runwithout--flavoruses dev.
The Xcode projects carry no development team. To run on a device or sign locally, create app/ios/Flutter/Signing.xcconfig (and app/macos/Runner/Configs/Signing.xcconfig for macOS) with your team id:
DEVELOPMENT_TEAM = ABCDE12345
Every flavor xcconfig includes it when it exists. Both files are gitignored; unsigned builds (flutter build ios --no-codesign) work without them.
The template supports what Flutter supports and moves when Flutter raises its minimums. An app may raise a minimum for its audience, never lower it.
| Platform | Minimum | Where it is set |
|---|---|---|
| Android | API 24 (Android 7.0), from Flutter | minSdk = flutter.minSdkVersion in app/android/app/build.gradle.kts, so it tracks Flutter |
| iOS | 15.0 | IPHONEOS_DEPLOYMENT_TARGET in app/ios/Runner.xcodeproj |
| macOS | 12.0 | MACOSX_DEPLOYMENT_TARGET in app/macos/Runner.xcodeproj |
| Windows | Windows 10 | Flutter's minimum; nothing to set |
| Linux | Debian 10 / Ubuntu 20.04 | Flutter's minimum; nothing to set |
| Web | Chrome, Edge and Firefox (latest two versions), Safari 15.6 | Flutter's supported browsers |
Android, iOS and web are first-class: every feature works there, and CI builds them on every PR. macOS, Windows and Linux are build-only: CI builds them after merge, and Modules whose native SDK is missing there (Firebase) switch themselves off.
Five workflows live in .github/workflows/. None of them needs a secret until you turn on releases or per-PR previews.
| Workflow | Runs on | Jobs |
|---|---|---|
ci.yaml |
every PR to main and every push to main |
check, build-android, build-ios, build-web |
desktop.yaml |
push to main, weekly (Monday 03:00 UTC), manual |
build-macos, build-windows, build-linux |
release.yaml |
a v* tag, only when RELEASE_ENABLED is true |
version, release-android, release-ios, release-web, github-release |
widgetbook.yaml |
push to main, manual, only when WIDGETBOOK_PUBLISH is true |
publish |
widgetbook-preview.yaml |
a PR to main from this repo (opened, pushed to, reopened, closed), only when WIDGETBOOK_PR_PREVIEW is true |
deploy, cleanup (on close) |
checkrunsmelos run genand fails when a generated file (*.g.dart,*.freezed.dart, generated localizations) is out of date, thenmelos run analyze(any lint info fails) andmelos run test. There is no format check.- Build jobs make unsigned prod release builds, so R8 and tree-shaking problems show up before a release.
build-ios(andbuild-macos) fails if aPodfileappears: every plugin must support Swift Package Manager. - Required checks: the four
ci.yamljobs are meant to block merges. Turn that on in branch protection (step 8 of Create a new app). - Desktop runs only after merge, because macOS and Windows minutes are expensive. GitHub turns off scheduled workflows after 60 days without repo activity, and a failed scheduled run only emails whoever last edited the workflow file.
- Runners come from the repository variables
LINUX_RUNNER,MACOS_RUNNERandWINDOWS_RUNNER(defaultsubuntu-latest,macos-latest,windows-latest), so a copy can switch runners without editing the workflows. - Dependabot (.github/dependabot.yml) updates pub packages, GitHub Actions and the
Gemfileweekly.
A Module is optional infrastructure in packages/<name>_module/ that plugs into Core's ErrorReporter, Analytics or RemoteFlags. The template ships every Module wired in, so its CI covers them all; an app removes the ones it doesn't want. Core skips a Module on platforms it doesn't support and falls back to no-op defaults.
Delete all of these, then run flutter pub get and dart run melos run analyze (a leftover import or dependency fails the analysis):
packages/<name>_module/- packages/<name>_modulein the rootpubspec.yamlworkspace list- its dependency in
app/pubspec.yaml - its dependency in
widgetbook/pubspec.yaml, and any use cases inwidgetbook/lib/that import it - its entry and import in
app/lib/app/modules.dart - its localizations delegate in
app/lib/app/l10n.dart, if it has strings (neither shipped Module does) - its line in
CLAUDE.mdand its section in this README - the Module's own extras: Firebase, Sentry
flutter pub get regenerates the macOS, Windows and Linux plugin registrants without the Module's plugins; commit them.
To add a Module back, copy its folder from the template and add the same lines again, plus the vendor's native setup.
packages/firebase_module provides Firebase Analytics and Remote Config through Core's Analytics and RemoteFlags. It runs on Android, iOS and web; on macOS, Windows and Linux Core skips it. Crash reporting is not part of it.
As shipped, Firebase is not configured. The options files in packages/firebase_module/lib/src/options/ are placeholders, so the Module's init throws Firebase not configured for <flavor>, Core logs that once, and the app runs on the no-op Analytics and RemoteFlags. There are no Firebase native files in app/.
Ordinary Remote Config fetches run at most every 12 hours (5 minutes on dev); real-time updates are not limited by that interval and are fetched as soon as the backend publishes them. Reads never wait on the network: they use the last cached values, a refresh runs in the background, and the real-time listener activates updates as they arrive, then fires RemoteFlags.onChanged. When a fetch fails, the cached values (or the fallbacks) stay.
Each flavor uses its own Firebase project. Two flavors can share one by passing the same --project.
- Install the Firebase CLI and
dart pub global activate flutterfire_cli, thenfirebase login. - Create the Firebase projects, then edit
--project,--android-package-nameand--ios-bundle-idin thefirebase:configure:<flavor>scripts in the root pubspec.yaml. Ids must matchapp/android/gradle.propertiesandapp/Identity.xcconfig(plus the iOS flavor suffixes). - Run, once per flavor:
Each script runs
dart run melos run firebase:configure:dev dart run melos run firebase:configure:staging dart run melos run firebase:configure:prod
flutterfire configureinapp/once per iOS build configuration (Debug-<flavor>,Profile-<flavor>,Release-<flavor>). It writes the Dart options file over the placeholder,app/android/app/src/<flavor>/google-services.json,app/ios/flavors/<flavor>/GoogleService-Info.plistandapp/firebase.json, adds the google-services Gradle plugin, and adds a "FlutterFire: bundle-service-file" build phase to the Runner target. - Commit every generated file. Firebase options are identifiers, not secrets; restrict the API keys in Google Cloud instead.
- Install
flutterfireon every Mac that builds iOS: the Xcode build phase calls it. Thebuild-iosjob inci.yamlandrelease.yamlalready install it onceapp/firebase.jsonexists. Add SHA fingerprints for each Android app, and link each flavor's web app to Google Analytics.
Analytics is linked without the advertising id (IDFA). Under Swift Package Manager, firebase_analytics decides this from the FIREBASE_ANALYTICS_WITHOUT_ADID environment variable while packages resolve, so it has to be set wherever iOS is built:
- CI: the
build-iosjob in .github/workflows/ci.yaml and therelease-iosjob in .github/workflows/release.yaml set it. - Locally:
export FIREBASE_ANALYTICS_WITHOUT_ADID=truein your shell profile beforeflutter build ios/flutter run. Xcode opened from the Dock does not see your shell; runlaunchctl setenv FIREBASE_ANALYTICS_WITHOUT_ADID trueand restart Xcode. - After changing it, clear the resolved packages (
flutter clean, then delete Xcode's DerivedData for the app) so the package resolves again.
Keep this setting unless the app needs IDFA, for example for cross-app tracking or ad attribution that depends on it. Showing ads alone does not require IDFA. An app that does need it removes the variable everywhere, then must ask for permission through App Tracking Transparency before tracking, declare the tracking in its privacy manifest and App Store privacy details, and collect any consent its regions require (for example GDPR consent in the EU).
Follow Remove a Module for firebase_module, then also delete:
- the
firebase:configure:*scripts in the rootpubspec.yaml FIREBASE_ANALYTICS_WITHOUT_ADIDin.github/workflows/ci.yamland.github/workflows/release.yaml(and in your shell orlaunchctl)- the "Install the FlutterFire CLI" steps in
.github/workflows/ci.yamland.github/workflows/release.yaml - the "Read the firebase_core version" and "Cache the Firebase C++ SDK" steps in the
build-windowsjob of.github/workflows/desktop.yaml - the
firebase-dependenciesgroup in.github/dependabot.yml
Once Firebase has been configured, also delete:
app/firebase.json- the
com.google.gms.google-serviceslines inapp/android/settings.gradle.ktsandapp/android/app/build.gradle.kts app/android/app/src/{dev,staging,prod}/google-services.jsonapp/ios/flavors/{dev,staging,prod}/GoogleService-Info.plist- the "FlutterFire: bundle-service-file" build phase on the Runner target (remove it in Xcode)
packages/sentry_module reports errors to Sentry through Core's ErrorReporter on all six platforms, and crash-free statistics on every platform except web (sentry_flutter starts web sessions only from its SentryNavigatorObserver, which the template does not add). Staging and prod report, with the flavor name as the Sentry environment; dev never starts Sentry. There is no tracing, session replay, screenshots, personal data or HTTP capture.
As shipped, Sentry is off. Set sentryDsn in packages/sentry_module/lib/src/sentry_options.dart to your project's DSN (Sentry project settings > Client Keys). One Sentry project serves every flavor. A DSN is not a secret. To get readable prod stack traces, set up the symbol upload in Releases.
Follow Remove a Module for sentry_module, then also delete:
- the
sentry_dart_plugindev dependency inapp/pubspec.yaml - the three "Upload ... to Sentry" steps in
.github/workflows/release.yaml, and the Sentry rows and notes in Releases - in
.github/workflows/desktop.yaml, the "Allow long paths in git" step ofbuild-windowsandlibcurl4-openssl-devinbuild-linux(both are there for Sentry's native SDK)
The template includes a complete authentication system with secure credential storage and error handling.
- Login & Registration: A retrofit client (
AuthApi) POSTs toapi/loginandapi/registerthrough Core's shared Dio. - Session: A successful login saves the token in Core's encrypted Hive box, so the user stays signed in after a restart. The app opens on a splash, then goes to home or login. Logout clears the token, and the back button can't undo it.
- Token refresh: On a 401, Core refreshes the token once through the auth Feature's optional
refreshhook and replays the failed requests. If the refresh is rejected (or there is nothing to refresh, as with reqres), the user is signed out, sees "Your session has expired" on the login screen, and comes back to the same page after signing in. A network error during the refresh keeps them signed in. - Permissions: Login completes only after the user's permission areas load (a demo loader here: profile, settings and orders, not reports). Each route declares its area; opening one outside them, even by a direct link, shows a "no permission" page. They are saved encrypted, reloaded once per launch and after a 403, and deleted on logout.
PermissionGateapplies the backend's rule for one control (hidden, read-only or greyed out; the home screen shows one of each), and the home screen's menu lists only granted areas, sending entries without a screen to an "under construction" page. Debug builds log permission keys the app checks that no loaded permissions contain. - Remember me: When ticked, the email and password are saved (encrypted) only to pre-fill the login form; they never sign the user in. Unticking deletes them; logout keeps them.
- Encryption key: One random AES-256 key, created on first launch and kept in the platform's secure storage (
flutter_secure_storage). Android backups exclude the Hive files and that key. - State Management: All authentication UI and logic is managed via Riverpod notifiers and state classes.
- Error Handling: Network failures become an
ApiExceptionand show as a translated snackbar. - Loading State: UI reflects loading and error states for a smooth UX.
You can test the authentication functionality using these credentials from reqres.in:
Login:
{
"email": "eve.holt@reqres.in",
"password": "cityslicka"
}Register:
{
"email": "eve.holt@reqres.in",
"password": "pistol"
}Example:
@riverpod
class LoginController extends _$LoginController {
// ... state and logic here
Future<LoginResponse> login({required String email, required String password}) async {
// Handles login, error handling, caching, loading state etc.
}
}widgetbook/ is the Catalog: a web-only Widgetbook app where Designers browse Core's screens, the demo screens and the Shared widgets: the no-permission and under-construction pages, the force-update dialog (blocking and dismissible), the error snackbar and error view for each ApiException kind, PermissionGate's states, and each state of the demo login and home screens, running on Fakes. Its toolbar switches the theme (the app's light and dark themes), the locale (en, tr), the device frame (phone, tablet, desktop) and the text scale. When you run it locally (dart run melos run widgetbook, a debug build) it also turns on accessibility checks; the Catalog published to GitHub Pages and the per-PR previews are release builds, where accessibility_tools checks nothing, so they have no accessibility addon.
It is its own workspace member that depends on the app, Core and each Module; the app never depends on it.
dart run melos run widgetbook(or flutter run -d chrome --print-dtd inside widgetbook/). --print-dtd prints the address of the Dart Tooling Daemon, which Claude can use to hot-reload the running catalog; see The loop.
Write a function annotated with @UseCase in a file whose path mirrors the widget's, for example widgetbook/lib/core/permission_gate.use_cases.dart for Core's PermissionGate, or widgetbook/lib/app/features/home/presentation/home_screen.use_cases.dart for the app's home screen. Screens that use Riverpod get their own ProviderScope with fake overrides (see FakeScope in the app's lib/fakes/). Each state of a screen (loading, empty, error, ...) is its own named use case with its own Fake setup: a FakeSeed for what storage holds, or a Fake variant such as FakeAuthRepository(loginError: ...). A use case driven by knobs may come on top, but it doesn't replace the named states. A Shared widget gets one use case per meaningful variant. Every app screen and Shared widget needs at least one; widgetbook/test/catalog_coverage_test.dart fails when one is missing. Then run dart run melos run gen, which regenerates widgetbook/lib/main.directories.g.dart; commit it. CI fails when it is out of date.
.github/workflows/widgetbook.yaml builds the catalog for the web and deploys it to GitHub Pages on every push to main and on manual dispatch. It does nothing until you opt in:
- In the repo's Settings > Pages, set the source to GitHub Actions.
- In Settings > Secrets and variables > Actions > Variables, add the repository variable
WIDGETBOOK_PUBLISHwith the valuetrue.
No secrets are needed.
.github/workflows/widgetbook-preview.yaml builds the catalog for the web (a release build) on every push to a pull request and deploys it to a Firebase Hosting preview channel named pr-<number>. The deploy posts a comment on the PR with the preview link, updates that comment on later pushes, and adds a check run. It is never a required check, so a preview outage doesn't block a merge. It is separate from the Pages publish above: you can turn on either one, both, or neither.
It does nothing until you opt in. One-time setup:
- A Firebase project for the catalog. Create a separate project in the Firebase console by default, not the one the Firebase Module uses: the preview belongs to Widgetbook, and removing either one then leaves the other alone. The no-cost Spark plan is enough.
- Hosting. In that project, open Hosting and click Get started, so the project has its default Hosting site. You don't need to deploy anything: the workflow's
widgetbook/firebase.jsonalready servesbuild/web. - A service account. In the Google Cloud console for the same project (IAM & Admin > Service accounts), create a service account with the role Firebase Hosting Admin, then create a JSON key for it (Keys > Add key > JSON) and download the file.
- The variable and the secret. In Settings > Secrets and variables > Actions, add the repository variable
WIDGETBOOK_FIREBASE_PROJECT_ID(the project id, not its display name) and the repository secretWIDGETBOOK_FIREBASE_SERVICE_ACCOUNT_JSON(paste the whole JSON key as text). Then delete the downloaded key file. - Turn it on. Add the repository variable
WIDGETBOOK_PR_PREVIEWwith the valuetrue. The next push to an open PR deploys its preview.
What to know:
- Fork PRs get no preview. GitHub gives a PR from a fork no secrets, so the workflow only runs for PRs from a branch of this repo. Review a fork PR's catalog locally with
dart run melos run widgetbook. - Preview URLs are public. Anyone with the link can open the catalog, so keep real data and secrets out of use cases (the template's use cases run on fakes).
- Channels clean themselves up. Each channel expires 14 days after its last deploy, and closing or merging the PR deletes it.
Delete all of these, then run flutter pub get:
widgetbook/(with itwidgetbook/firebase.json, the preview's Hosting config, andwidgetbook/test/catalog_coverage_test.dart, the half of the coverage meta-test that checks every screen and Shared widget has a use case)- widgetbookin the rootpubspec.yamlworkspace list- the
dependency_overridesblock foranalyzerin the rootpubspec.yaml(it exists only forwidgetbook_generator) - the
widgetbookMelos script in the rootpubspec.yaml .github/workflows/widgetbook.yamland.github/workflows/widgetbook-preview.yaml- the
"/widgetbook"line in.github/dependabot.yml - the
widgetbook/line and the other Widgetbook mentions inCLAUDE.md, and this section of the README
If you turned on previews, also delete the repository variables WIDGETBOOK_PR_PREVIEW and WIDGETBOOK_FIREBASE_PROJECT_ID and the secret WIDGETBOOK_FIREBASE_SERVICE_ACCOUNT_JSON, and delete the Firebase preview project (or at least its service account). If you turned on Pages, delete WIDGETBOOK_PUBLISH too.
Reword the Designer section instead of deleting it: the UI-file boundary, the guard and the rules stay, and the Catalog, hot-reload and hosted-catalog steps go.
This section is for the Designer, the person who shapes how the app looks: its tokens, its Shared widgets and the look of its screens. It is all you need to read.
A UI file is any file under app/lib/features/<feature>/presentation/ or app/lib/shared/widgets/ except *_controller.dart, *_ui_model.dart and generated files (*.g.dart, *.freezed.dart). UI files belong to the Designer; controllers and UI models belong to developers.
| Designer owns | Developers own |
|---|---|
| UI files: screens and their widgets | Controllers (*_controller.dart) and UI models (*_ui_model.dart) |
The design tokens and theme: app/lib/app/tokens.dart and app/lib/app/theme.dart |
data/ (repositories and API clients) and domain/ (models) |
Shared widgets in app/lib/shared/widgets/ |
The router and SGRoute (app/lib/router/) |
The Catalog: widgetbook/ |
packages/ (Core, the Modules, the lints) |
Widget tests under app/test/ |
The app's setup files: app/lib/app/config.dart, modules.dart, overrides.dart, setup.dart, app/lib/main*.dart and app/lib/my_app.dart |
The prose of DESIGN.md |
-
Flutter. Install Flutter 3.47.4 or later, and Chrome.
-
The repo. Clone it and install its dependencies from the repo root:
git clone <this repo's URL> cd <the repo folder> flutter pub get
-
Claude Code. Install Claude Code and run
claudefrom the repo root. The first time, it asks you to trust the folder and to approve the Dart MCP server; approve both. That server is how Claude hot-reloads the Catalog. -
impeccable. Turn on the design plugin:
claude plugin install impeccable@impeccable
Use either impeccable's plugin or its standalone skill, not both, or Claude sees two copies.
-
Your guard. Two personal files keep your Claude out of the files developers own. Both are gitignored: never commit them.
.claude/settings.local.json(if it already exists, add thedenylist to itspermissions):{ "permissions": { "deny": [ "Edit(**/data/**)", "Edit(**/domain/**)", "Edit(**/*_controller.dart)", "Edit(**/*_ui_model.dart)", "Edit(app/lib/router/**)", "Edit(packages/**)", "Edit(app/lib/app/config.dart)", "Edit(app/lib/app/modules.dart)", "Edit(app/lib/app/overrides.dart)", "Edit(app/lib/app/setup.dart)", "Edit(app/lib/main*.dart)", "Edit(app/lib/my_app.dart)" ] } }An
Edit(...)rule covers every built-in tool that changes a file. Keep them asEdit(...): Claude Code never checks aWrite(...)path rule. The paths are relative to where you start Claude, so always start it from the repo root.CLAUDE.local.md, at the repo root:I'm the Designer. Edit only UI files, the tokens and theme, `DESIGN.md`, tests and the catalog. If a change needs state the UI model doesn't have, write it in the PR description and stop.
This is a strong guard, not a wall. The deny rules stop Claude's built-in edit tools and the Bash file commands Claude Code recognizes (such as
sed,teeand>>redirects), but not a script that opens files itself. CI enforces only the import boundary, through the lints (a UI file can't importdata/or read a repository); nothing in CI checks which files a PR touches.
- Start the Catalog. In your own terminal, from the repo root:
It opens the Catalog in Chrome and prints a line
dart run melos run widgetbook
The Dart Tooling Daemon is available at: ws://.... Leave it running. - Ask Claude for the change. Run
claudefrom the repo root in another terminal and describe what you want. Claude edits UI files and hot-reloads the running Catalog after each edit. It finds the Catalog itself through the Dart MCP server: itsdtdtool lists the running apps, and Claude connects to the one whose workspace iswidgetbook/. If it finds none, or picks another app (one your IDE started), paste it thews://...address the Catalog printed. A hot reload keeps the Catalog open where it was; it is not a restart. - One use case per state. Each state of a screen (loading, empty, error, ...) is its own named use case in the Catalog, running on Fakes, so you design each state directly. Ask Claude for a new use case when you add a state; see Add a use case.
- Test. A new screen or Shared widget needs a widget test at its mirrored path under
app/test/and a use case at its mirrored path underwidgetbook/lib/app/. Ask Claude to write the test through thetest-writeragent. CI's coverage meta-test fails the PR when either one is missing. - When the data isn't there. If a change needs state the UI model doesn't have (a field it doesn't hold, a state it doesn't have), Claude says so and stops before touching the controller or UI model. Write the request in the PR description: what the screen needs and when. You can keep going meanwhile: build the look as a widget that takes its data as parameters, with its own use case.
- Open a PR. Commit on a branch and open a pull request to
main. Have Claude rundart run melos run analyzeanddart run melos run testfirst; CI runs them too. - A developer finishes the state. For a request in the PR description, a developer pushes the field and the wiring to your branch, so one PR merges with the state working.
When the repo has per-PR previews turned on (the WIDGETBOOK_PR_PREVIEW variable, see Preview every PR), each PR from a branch of this repo gets its own hosted Catalog: the workflow comments the link on the PR and updates it on every push. Reviewers open that link instead of checking out your branch.
- No link, local review. A PR from a fork never gets a preview, and no PR does while previews are off. Review those with a local
dart run melos run widgetbookon the PR's branch. - Preview URLs are public. Anyone with the link can open the Catalog, so keep real data and secrets out of use cases.
Claude and impeccable read two files at the repo root before changing a UI file:
DESIGN.mddescribes the design system. Edit its prose freely. Its frontmatter (the block between the---lines at the top: colors, type scale, radii, spacing) is generated from the theme, so never edit it by hand: change the token inapp/lib/app/tokens.dart, then rundart run melos run design:updateand commit the result. CI fails when the frontmatter is out of date. Don't run/impeccable document: it rewrites the whole file.PRODUCT.mdsays what the app is for and who it serves. It ships as[TODO]headings: fill them in, or run/impeccable teachto be interviewed for it.
One developer reviews the PR against its hosted Catalog link (or, when there is none, against a local dart run melos run widgetbook) and approves it. "Require one approval" is a branch-protection setting on main, set in step 8 of Create a new app; there is no CODEOWNERS file. After the merge, main's Catalog is published to GitHub Pages if the repo has Pages publishing on.
.github/workflows/release.yaml ships the prod flavor when you push a version tag (v*):
- Android: an obfuscated App Bundle, signed with your upload keystore, goes to Play's internal track (
supply). - iOS:
match(readonly) fetches the App Store certificate and profile,update_code_signing_settingsapplies them,gymarchives the obfuscated build and the IPA goes to TestFlight. - Web: a release build with source maps. Hosting is up to you; the build is attached to the GitHub Release as
web.zip(without the.mapfiles). - Sentry: each job uploads its debug symbols (web: source maps) with
sentry_dart_plugin, under the same release and dist that the Sentry Module reports. - GitHub Release: once all three jobs succeed, the tag's release gets the App Bundle, the iOS archive, the Dart debug symbols (plus the R8 mapping) and the web build.
It does nothing until you opt in: every job runs only when the repository variable RELEASE_ENABLED is true. The template sets nothing, so its tags skip cleanly.
The lanes are in app/android/fastlane and app/ios/fastlane; fastlane is pinned by the root Gemfile and Gemfile.lock (Dependabot updates them). Each lane runs flutter build itself, then signs and uploads. To run one locally: bundle install at the root, then bundle exec fastlane android release from app/android (or ios release from app/ios) with the same environment variables set. The Android lane also takes the keystore from app/android/key.properties (below) and the Play key from json_key_file in its Appfile.
The version comes from version: <name>+<build> in app/pubspec.yaml, and the tag must be v<name> (the version job fails otherwise). A tag with a - (v1.2.0-rc.1) makes a prerelease. Bump the build number for every release: Play and App Store Connect reject a build number they have seen before.
The Sentry release must match what sentry_flutter reports, <package id>@<name>+<build> with dist <build>, and the package id differs per platform. The workflow reads it from the same files the build does:
| Platform | Release | Read from |
|---|---|---|
| Android | <application id>@<name>+<build> |
app.applicationId + app.prod.applicationIdSuffix in app/android/gradle.properties |
| iOS | <bundle id>@<name>+<build> |
IDENTITY_BUNDLE_ID in app/Identity.xcconfig |
| Web | <pubspec name>@<name>+<build> |
name in app/pubspec.yaml (web has no package id) |
- Set your own ids (Customize names and IDs). The lanes refuse to upload while the id is still
com.example.temp. - Android: create an upload keystore (
keytool -genkeypair -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload) and keep it outside the repo. Create the app in Play Console and upload the first App Bundle by hand: the Play API can't create an app or make its first upload. Then create a Google Cloud service account with release access to the app in Play Console and download its JSON key. - iOS: create the app in App Store Connect and an App Store Connect API key (Users and Access > Integrations, App Manager role). Create an empty private git repo for the certificates. Then, once, someone with App Store access runs
bundle exec fastlane match appstorefromapp/ios(withMATCH_GIT_URLset, or the placeholder in Matchfile replaced) to create the certificate and profile; CI only reads them. The gitignoredSigning.xcconfigis for local development only; CI doesn't need it. - Sentry (optional): set
sentryDsn(Sentry Module) and create an auth token with theproject:releasesandorg:readscopes. Without theSENTRY_PROJECTvariable the upload steps are skipped, and prod stack traces stay obfuscated. - Add the secrets and variables below (Settings > Secrets and variables > Actions), then set
RELEASE_ENABLEDtotrue.
| Name | Kind | Use |
|---|---|---|
RELEASE_ENABLED |
variable | true turns the release jobs on |
ANDROID_KEYSTORE_BASE64 |
secret | the upload keystore, base64 (base64 -i upload-keystore.jks | pbcopy on macOS, base64 -w0 upload-keystore.jks on Linux) |
ANDROID_KEYSTORE_PASSWORD |
secret | the keystore password |
ANDROID_KEY_ALIAS |
secret | the key alias (upload in the command above) |
ANDROID_KEY_PASSWORD |
secret | the key password |
PLAY_SERVICE_ACCOUNT_JSON |
secret | the service account's JSON key, pasted as text, for supply |
MATCH_GIT_URL |
variable | HTTPS URL of the private certificates repo |
MATCH_PASSWORD |
secret | the passphrase match encrypts the certificates with |
MATCH_GIT_BASIC_AUTHORIZATION |
secret | base64("<user>:<token>") for a token that can read the certificates repo |
APP_STORE_CONNECT_API_KEY_ID |
secret | the API key's id |
APP_STORE_CONNECT_ISSUER_ID |
secret | the issuer id shown above the keys list |
APP_STORE_CONNECT_API_KEY |
secret | the contents of the downloaded .p8 file, pasted as text |
SENTRY_AUTH_TOKEN |
secret | Sentry auth token for the symbol upload |
SENTRY_ORG |
variable | Sentry organization slug |
SENTRY_PROJECT |
variable | Sentry project slug; when unset, the Sentry upload steps are skipped |
The runner labels come from LINUX_RUNNER and MACOS_RUNNER, as in CI.
Never commit keystores, key.properties, .p8 keys or service-account JSON; .gitignore already excludes them.
app/android/app/build.gradle.kts signs release builds with the upload keystore when one is set, and with the debug keys otherwise (so flutter run --release and CI's unsigned builds keep working). CI sets ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD. Locally, create the gitignored app/android/key.properties instead:
storeFile=/absolute/path/to/upload-keystore.jks
storePassword=...
keyAlias=upload
keyPassword=...
A relative storeFile is resolved from app/android/.
The upload runs with commits=false, so Sentry doesn't link commits to the release. To turn that on, connect the repository in Sentry, check out with fetch-depth: 0 in the jobs that upload, and drop --sentry-define=commits=false.
- Tests mirror
lib/in every package:app/lib/features/home/presentation/home_screen.dartis tested inapp/test/features/home/presentation/home_screen_test.dart. Tests that span several files go inapp/test/app/(flows such as the session flow: restart, redirect and logout, and the permission guard, gates and menu),app/test/l10n/orapp/test/conventions/. - Every screen and Shared widget has a widget test and a Widgetbook use case at its mirrored paths. The coverage meta-test checks both by path:
app/test/conventions/screen_tests_test.dartfor the tests andwidgetbook/test/catalog_coverage_test.dartfor the use cases. - Tests and the catalog share hand-written Fakes:
FakeStorageandFakeScopeinapp/lib/fakes/, and each Feature's own in itsfakes/folder (for exampleFakeAuthRepository). There are no mocks. - The details are in
.claude/rules/testing.md. - Run tests in every package with:
dart run melos run test
The repo ships instructions for Claude Code, which people can read too:
- CLAUDE.md is short and loads in every session: the working style, the rules that always apply, the repo layout and the commands.
.claude/rules/holds the detailed rules, one file per area. Each file loads only when Claude reads or edits a file itspaths:match, so a session pays only for the rules it needs. A rule drops out of the context at/compactand loads again on the next matching read.
| File | Covers |
|---|---|
presentation.md |
UI files (screens and their widgets, Shared widgets), the catalog's use cases, controllers and UI models |
theming.md |
The theme, design tokens, fonts, strings and ARB files, DESIGN.md |
networking.md |
The shared Dio, retrofit clients, ApiException and showing errors |
session-permissions.md |
The Session, token refresh, permissions and local storage |
routing.md |
GoRouter, the redirects and the permission guard |
logging.md |
Logging, breadcrumbs and error reporting, the Sentry Module |
shell.md |
Entry points, flavors, config, Modules, force update, platforms and minimum OS |
widgetbook.md |
The Widgetbook catalog and its publish and per-PR preview workflows |
ci-release.md |
CI, desktop builds, releases, fastlane and Dependabot |
lint-plugins.md |
The app_lints analyzer plugin |
testing.md |
Tests and their helpers |
.claude/agents/test-writer.mdis a subagent that writes tests from requirements (a new feature, a bug reproduction) without reading the implementation. Claude hands work to it on its own, or ask for it by name..claude/settings.jsonrecommends plugins to everyone who clones the repo. It registers two plugin marketplaces and turns on only the dart-flutter plugin (the Dart MCP server and the Dart and Flutter skills); Claude Code asks once to trust the folder, and once to approve the MCP server. impeccable, the design plugin, is registered but off: a Designer turns it on withclaude plugin install impeccable@impeccable. Use either impeccable's plugin or its standalone skill, not both, or Claude sees two copies. Claude Code on the web ignores this file.- Personal files are gitignored:
CLAUDE.local.mdfor your own instructions and.claude/settings.local.jsonfor your own permissions (the Designer's guard lives there, see Set up once). They never reach anyone else, and Claude Code on the web doesn't see them.
- A Company copy commits its rules as
.claude/rules/company-*.mdand never edits the template's files, so updates from the Public template merge without conflicts. - A Derived app adds
.claude/rules/app-*.md, or edits the template's rules freely, since it never merges updates.
- Up-to-date documentation in this README
- CLAUDE.md and
.claude/rules/: the architecture, commands and the rules the code follows (see Claude Code) - CONTEXT.md: the project's vocabulary (Module, Feature, Flavor, Session, ...)
- PRODUCT.md: what the app is for and who it serves, for Claude and impeccable; ships as a
[TODO]skeleton - DESIGN.md: the design system in Google Stitch format; its frontmatter is generated from the theme (
dart run melos run design:update), its prose is written by hand - Code comments and examples throughout
See our Contributing Guide for details on how to:
- Set up your development environment
- Run tests
- Submit pull requests
This project is licensed under the MIT License - see the LICENSE file for details.