# flutter app setup a copyable agent playbook for starting a production-ready Flutter app with feature modules, GoRouter, Riverpod, Drift, KV storage, startup wiring, base UI, logging, permissions, and a Dio API client. ## task integrate this component into the user's codebase. the complete implementation is at the end of this file. everything you need is here. ## steps 1. inspect the host repository before changing anything. preserve its existing naming, state-management, auth, and environment conventions unless this document explicitly asks you to add the missing piece. 2. implement the architecture and files described below. use the package versions and generated-code commands that are compatible with the host project's current Flutter/Dart SDK. 3. keep the first pass schema-free: create the Drift database and its migration hook, but do not invent product tables or domain logic. 4. run code generation, formatting, static analysis, and the available tests. fix errors introduced by this integration before finishing. 5. report every file changed, every command run, and any platform setup that still needs a human decision. ## constraints - do not vendor this into a package or split it across files. it is designed to be one file. - do not silently change the public api. if the user needs a different api, say so rather than editing the source. ## reference human readable version: https://www.stormej.me/trove/flutter-app-setup stack: flutter, dart, riverpod, go_router, drift the full api docs, behaviour notes, and source follow. --- ## tldr Copy the agent prompt from this page, or fetch its `llms.txt`, into an agent when starting a Flutter app. It tells the agent to inspect the existing repo, create the foundation in small verified steps, and leave product-specific schema and features alone. This is intentionally an orchestrator, not a package. The result should be a normal Flutter project whose files remain easy to own and change. ## agent brief Give the agent this brief after it has inspected the host repository: > Set up this Flutter app foundation. Preserve the host project's existing > brand, package name, auth decisions, and state-management conventions. Use > Riverpod with generated providers when Riverpod is already present. Do not > add product tables, screens, or business logic beyond a minimal home route. > Keep generated `.g.dart` files out of hand-written edits; run build_runner. > Run format, analyze, tests, and code generation before reporting completion. ## target structure Create this structure, omitting only files made unnecessary by conventions already present in the host app: ```text lib/ ├── main.dart └── src/ ├── core/ │ ├── app.dart │ ├── errors/ │ │ └── failure.dart │ ├── utils/ │ │ └── either.dart │ ├── config/ │ │ └── app_config.dart │ └── ui/ │ ├── theme/ │ │ ├── app_theme.dart │ │ ├── app_scroll_behavior.dart │ │ └── theme_mode_provider.dart │ └── widgets/ │ ├── app_button.dart │ ├── app_text_field.dart │ ├── app_error_widget.dart │ ├── app_spinner.dart │ ├── app_empty_state.dart │ └── app_toast.dart ├── modules/ │ ├── startup/ │ │ ├── presentation/providers/startup_provider.dart │ │ └── presentation/ui/startup_view.dart │ ├── home/presentation/ui/home_view.dart │ └── not_found/presentation/ui/not_found_view.dart └── services/ ├── api/api_client.dart ├── api/api_provider.dart ├── api/enums/ ├── api/models/ ├── db/ │ ├── app_database.dart │ ├── app_database_provider.dart │ ├── kv_store.dart │ └── kv_store_keys.dart ├── logger/app_logger.dart ├── logger/app_logger_provider.dart ├── permissions/permission_service.dart └── router/ ├── app_router.dart └── route_config.dart ``` The dependency direction is one-way: ```text presentation → feature/domain → services → core ``` Services must not import widgets. Feature modules own their screens and providers. Shared widgets stay deliberately small and have no feature imports. ## example feature module Create one small `example` feature so the architecture is demonstrated by working code rather than empty folders. Follow [Flutter's recommended MVVM shape](https://docs.flutter.dev/app-architecture/guide): a view renders state, a one-to-one view model owns UI state and commands, and repositories are the source of truth over services. The domain layer is optional in Flutter's guidance, so include it in this dummy feature to show the boundary, but add use-cases only when logic is complex or reusable. ```text lib/src/modules/example/ ├── data/ │ ├── datasources/ │ │ └── example_api_service.dart │ ├── models/ │ │ └── example_dto.dart │ └── repositories/ │ └── example_repository_impl.dart ├── domain/ │ ├── entities/ │ │ └── example.dart │ ├── repositories/ │ │ └── example_repository.dart │ └── usecases/ │ └── load_example.dart └── presentation/ ├── providers/ │ └── example_providers.dart ├── view_models/ │ └── example_view_model.dart ├── views/ │ └── example_view.dart └── widgets/ └── example_content.dart ``` The dependency flow is: ```text ExampleView ↓ commands/state ExampleViewModel ↓ LoadExample (only when useful) ↓ ExampleRepository ↓ ExampleApiService → ApiClient ``` ### model and service Keep transport models in `data/models` and map them into domain entities. The service should know endpoints and decoding, but not presentation state: ```dart final class ExampleDto { const ExampleDto({required this.id, required this.title}); final String id; final String title; factory ExampleDto.fromJson(Map json) => ExampleDto( id: json["id"] as String, title: json["title"] as String, ); Example toEntity() => Example(id: id, title: title); } final class ExampleApiService { const ExampleApiService(this._api); final ApiClient _api; Future> fetchExample() { return _api.sendRequest( "/example", method: MethodType.get, decode: (data) => ExampleDto.fromJson(data! as Map), ); } } ``` ### repository and optional use-case The repository owns caching, refresh, error policy, and conversion to domain models. It must not know about widgets or `BuildContext`: ```dart abstract interface class ExampleRepository { Future> load(); Future> refresh(); } final class ExampleRepositoryImpl implements ExampleRepository { ExampleRepositoryImpl(this._service); final ExampleApiService _service; Example? _cached; @override Future> load() async { final cached = _cached; if (cached != null) return Right(cached); return refresh(); } @override Future> refresh() async { final result = await _service.fetchExample(); return result.fold( Left.new, (dto) { final entity = dto.toEntity(); _cached = entity; return Right(entity); }, ); } } ``` `LoadExample` may simply delegate to the repository in this dummy module. It exists to show where genuinely complex or reusable domain logic belongs; do not create a use-case for every trivial method. ### Riverpod provider and view model Providers compose the service, repository, and view model. Keep generated files out of hand-written code and run the generator afterward: ```dart @riverpod ExampleApiService exampleApiService(Ref ref) => ExampleApiService(ref.watch(apiClientProvider)); @riverpod ExampleRepository exampleRepository(Ref ref) => ExampleRepositoryImpl(ref.watch(exampleApiServiceProvider)); @riverpod class ExampleViewModel extends _$ExampleViewModel { @override Future build() async { final result = await ref.watch(exampleRepositoryProvider).load(); return result.fold((failure) => throw failure, (value) => value); } Future refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { final result = await ref.read(exampleRepositoryProvider).refresh(); return result.fold((failure) => throw failure, (value) => value); }); } } ``` The view should only render `AsyncValue`, call view-model commands, and contain layout/routing logic. It must not call the API, repository, database, or `jsonDecode` directly: ```dart class ExampleView extends ConsumerWidget { const ExampleView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(exampleViewModelProvider); return Scaffold( appBar: AppBar(title: const Text("Example")), body: state.when( loading: () => const AppSpinner(), error: (error, stackTrace) => AppErrorWidget( error: error, onRetry: () => ref.read(exampleViewModelProvider.notifier).refresh(), ), data: (example) => ExampleContent(example: example), ), ); } } ``` Register the example route and make it the minimal home route only to prove the whole vertical slice works. Once verified, the agent should treat this module as a template and rename it for the first real feature. ## repository detection Before creating files, determine whether the Flutter app lives inside a JavaScript monorepo: ```text Turbo repo when: turbo.json exists at the workspace root AND a root package.json has turbo available directly or in devDependencies Package workspace when: package.json has workspaces, or pnpm-workspace.yaml exists Flutter app root when: pubspec.yaml exists in the current directory or apps/mobile/ ``` If `turbo.json` exists, do not create a second nested Turborepo. Treat the Flutter directory as one package in the existing workspace, preserve its package-manager lockfile, and inspect the existing `packageManager` field before choosing Bun, pnpm, npm, or Yarn commands. If the repository is not a Turborepo, do not add Turbo merely to run Flutter commands. ## Turborepo commands When the detection above succeeds and the Flutter app is at `apps/mobile`, add an `apps/mobile/package.json` with package-local task logic. Use the actual package manager's command runner if the repo already has one: ```json { "name": "mobile", "private": true, "scripts": { "get": "flutter pub get", "generate": "dart run build_runner build --delete-conflicting-outputs", "generate:watch": "dart run build_runner watch --delete-conflicting-outputs", "format": "dart format lib test", "analyze": "flutter analyze", "test": "flutter test", "build:android": "flutter build apk", "build:ios": "flutter build ios --no-codesign", "clean": "flutter clean" } } ``` Merge with an existing mobile `package.json`; never overwrite its scripts or metadata. If the mobile app lives elsewhere, use that relative package path and name. Prefer `dart format` and `flutter analyze` over JavaScript formatters for Dart files. Register package tasks in the root `turbo.json`. Preserve all existing tasks and merge these entries rather than replacing the file: ```json { "tasks": { "get": { "cache": false }, "generate": { "cache": false }, "generate:watch": { "cache": false, "persistent": true }, "format": { "cache": false }, "analyze": {}, "test": {}, "build:android": { "dependsOn": ["generate"], "outputs": ["build/**"] }, "build:ios": { "dependsOn": ["generate"], "outputs": ["build/**"] }, "clean": { "cache": false } } } ``` Add root delegating scripts only when the root `package.json` already exposes workspace commands. They must delegate through `turbo run`; task logic belongs to `apps/mobile/package.json`: ```json { "scripts": { "mobile:get": "turbo run get --filter=mobile", "mobile:generate": "turbo run generate --filter=mobile", "mobile:generate:watch": "turbo run generate:watch --filter=mobile", "mobile:format": "turbo run format --filter=mobile", "mobile:analyze": "turbo run analyze --filter=mobile", "mobile:test": "turbo run test --filter=mobile", "mobile:build:android": "turbo run build:android --filter=mobile", "mobile:build:ios": "turbo run build:ios --filter=mobile" } } ``` For a changed-package CI check, use `turbo run analyze test --affected` with the repository's configured base branch. Do not put `cd apps/mobile && flutter ...` task logic in the root package scripts, and do not use the `turbo` shorthand inside committed JSON or CI configuration. ## dependencies Add only the packages that are missing, using versions compatible with the project's SDK: ```yaml dependencies: dio: ^5.11.0 drift: ^2.34.3 drift_flutter: ^0.3.1 go_router: ^17.3.0 logging: ^1.3.0 path_provider: ^2.1.6 permission_handler: ^13.0.1 shared_preferences: ^2.5.5 flutter_riverpod: ^3.4.2 riverpod_annotation: ^4.0.6 dev_dependencies: build_runner: ^2.16.0 drift_dev: ^2.34.3 riverpod_generator: ^4.0.8 ``` Use `flutter pub add` where possible so the resolver selects compatible versions. These are the current stable versions checked against pub.dev on 2026-08-14; resolve again in the target repository because SDK constraints and new releases can change. Use `hooks_riverpod` only when the host already uses hooks. ## bootstrap and startup `main.dart` should initialize bindings, install top-level error handlers, and mount a normal `ProviderScope`: ```dart void main() { WidgetsFlutterBinding.ensureInitialized(); installAppErrorHandlers(); runApp(const ProviderScope(child: App())); } ``` `App` owns `MaterialApp.router` and watches one `startupProvider`. Render three states: splash while loading, a retryable error view on failure, and the router once ready. Configure light theme, dark theme, theme mode, clamped text scaling, and app scroll behavior here. Startup should initialize, in order: 1. environment/configuration; 2. `KvStore`; 3. theme mode; 4. the Drift database; 5. auth/session restoration if the app has auth; 6. logger subscription; 7. other platform services and permissions only when needed. Make startup idempotent and expose retry through `ref.invalidate(startupProvider)`. Do not put arbitrary delays in startup. `FlutterError.onError`, `PlatformDispatcher.instance.onError`, and `runZonedGuarded` must be installed before `runApp` so framework and startup failures are captured. They cannot all be moved into `startupProvider`: that provider runs only after the widget tree has started building. It is correct to keep the handlers in a small `core/errors/app_error_handlers.dart` installer called by `main`, while initializing the logger/crash-reporting sink itself in `startupProvider`. ## routing with GoRouter Keep route strings in `route_config.dart`, keep route declarations together, and keep the router in `app_router.dart`. Use a static navigator key so services such as toasts and notification handlers can navigate without a `BuildContext`: ```dart class AppRouter { static final navigatorKey = GlobalKey(); static final router = GoRouter( navigatorKey: navigatorKey, initialLocation: RoutePaths.root, routes: AppRoutes.routes, errorBuilder: (context, state) => NotFoundView(path: state.uri.toString()), ); static void go(String location, {Object? extra}) => router.go(location, extra: extra); } ``` If auth exists, add the repository's auth listenable as `refreshListenable` and keep redirects pure: unauthenticated users go to login, authenticated users do not remain on login. Validate `state.extra` before casting and show the not-found screen when required route data is absent. ## themes and base components Create `AppTheme.lightTheme` and `AppTheme.darkTheme` from shared color, spacing, radius, and typography tokens. Components must consume `Theme.of(context)` or the app theme extension; no feature screen should carry its own global colors. Implement small baseline components with accessible semantics: - `AppButton`: primary, secondary, and text variants; loading and disabled states; minimum tap target of 44 logical pixels. - `AppTextField`: label, hint, error, keyboard type, and autofill support. - `AppSpinner`: a semantic progress indicator. - `AppErrorWidget`: readable message plus an optional retry action. - `AppEmptyState`: title, description, and optional action. - `AppToast`: success, error, warning, and info events; a global navigator key; no context required; safe-area aware; dismissible; accessible live-region semantics. Prefer a simple overlay/`OverlayEntry` implementation first. Do not turn these into a design system with speculative APIs. Keep them copyable and easy to replace. ## persistence under `services/db` Keep every persistence concern under `lib/src/services/db`, including Drift, the database provider, KV storage, KV keys, migrations, converters, and future DAOs. Do not create a separate `services/local_storage` or top-level database folder. Wrap `SharedPreferencesAsync` behind a typed `KvStore`. Initialize it once during startup, define keys in `KvStoreKeys`, support `String`, `int`, `bool`, `double`, and `List`, and expose async `get`, `set`, `remove`, and `clear`. `SharedPreferences` is a legacy API; use the newer async API for new projects. Do not store secrets in ordinary preferences. If auth tokens are needed, use the auth SDK's secure storage or add a secure-storage adapter explicitly. ## Drift database Create an `AppDatabase extends _$AppDatabase` with an empty `@DriftDatabase` table list and `schemaVersion = 1`. Open it with `driftDatabase(name: "app")`, keep a singleton provider, expose `forTesting(QueryExecutor executor)`, and close it from provider disposal. Keep a `MigrationStrategy` ready for future tables, but do not invent schema now. Run generation after the database and provider files exist. The one-shot command is for CI and completed changes: ```bash dart run build_runner build --delete-conflicting-outputs ``` During implementation, use watch mode so Riverpod and Drift generated files stay current while the agent or developer edits Dart files: ```bash dart run build_runner watch --delete-conflicting-outputs ``` If the repository has the Turborepo package scripts above, use `generate:watch` for the long-running process. Mark it persistent and uncached; never run a persistent watch task as a dependency of a build task. ## logger Wrap the `logging` package in `AppLogger`. Set `Logger.root.level`, subscribe once during startup, use `debugPrint` outside release builds, and leave a callback/sink for release error reporting. Return the stream subscription so startup can cancel it. Never use `print` or `console` logging in app code. ## permissions Keep `permission_handler` behind an interface: ```dart enum AppPermissionStatus { granted, denied, permanentlyDenied, restricted } abstract interface class PermissionService { Future statusForNotifications(); Future requestNotifications(); Future openSettings(); } ``` Map `PermissionStatus.granted` and `.limited` to `granted`, map permanent and restricted states explicitly, and return `denied` for the remaining states. Do not request permissions during app construction; request them from the feature that needs them and provide an “open settings” path for permanent denials. Mention required Android/iOS manifest or `Info.plist` changes in the handoff. ## Either and failures Use a typed `Either` instead of nullable tuples or throwing expected API failures through feature code: ```dart sealed class Either { const Either(); T fold(T Function(L left) onLeft, T Function(R right) onRight); } final class Left extends Either { const Left(this.value); final L value; @override T fold(T Function(L left) onLeft, T Function(R right) onRight) => onLeft(value); } final class Right extends Either { const Right(this.value); final R value; @override T fold(T Function(L left) onLeft, T Function(R right) onRight) => onRight(value); } ``` Keep `Failure` abstract and extensible. At minimum add `NetworkFailure`, `TimeoutFailure`, `UnauthorizedFailure`, `InvalidInputFailure`, `NotFoundFailure`, `ConflictFailure`, `RateLimitFailure`, `ServerFailure`, and `UnknownFailure`. Every failure should carry a user-safe `errorMessage`, and may carry status code, original exception, stack trace, request id, and parsed server details. Never expose raw response bodies directly to users. ## API client Use one provider-owned Dio client with a practical, typed surface. The agent should create `MethodType`, `RequestType`, `MultipartBody`, and `ApiFailure` models under `services/api`, and a generic method shaped like this: ```dart Future> sendRequest( String path, { required MethodType method, required T Function(Object? data) decode, RequestType requestType = RequestType.json, Object? body, MultipartBody? multipartBody, Map? queryParameters, Map? headers, bool requiresAuth = true, int retryCount = 0, }); ``` The client must: - attach the current bearer token and app/account headers through an interceptor, without overwriting explicitly supplied headers; - support GET, POST, PUT, PATCH, and DELETE, including empty 204 responses; - map Dio exceptions and status codes into the typed `Failure` subclasses; - safely decode an error body even when it is empty, invalid JSON, or a string; - retry 401 once through an injected token-refresh callback, then return `UnauthorizedFailure` and invoke an injected session-expired callback; - expose request cancellation, connect/send/receive timeouts, and optional progress callbacks; - return decoded DTOs, never `dynamic` values, from feature-facing methods. Multipart should use Dio's `FormData` and `MultipartFile.fromFile`, not a second HTTP stack: ```dart final body = MultipartBody( fields: {"title": title}, files: [ApiFile(field: "image", path: imagePath, filename: "cover.jpg")], ); final result = await api.sendRequest( "/uploads", method: MethodType.post, requestType: RequestType.multipart, multipartBody: body, decode: (data) => UploadDto.fromJson(data! as Map), ); ``` For SSE, use the same Dio client with `ResponseType.stream` and return a typed `Stream>`. Parse `event:` and `data:` frames, support multiline data, emit a final buffered event, cancel the request when the subscription is cancelled, and map stream/network/auth failures through the same `Failure` model. Do not force SSE through the normal JSON decoder. ## completion checklist The agent is done only when: - the app boots into a minimal home screen through GoRouter; - startup has loading, ready, and retryable error states; - light/dark themes and theme persistence work; - KV storage and an empty Drift database initialize successfully; - base components compile and are used by the startup/error screens; - the logger is installed once and has no raw `print` calls; - permission access is behind the service interface; - the API client has JSON, multipart, SSE, timeouts, auth injection, 401 handling, typed errors, and a provider; - `Either` is the feature-facing result type and expected failures are not represented by nullable tuples; - the example feature works end to end: view → view model → optional use-case → repository → API service → API client; - generated files are up to date; - code generation has been run once with `build_runner build --delete-conflicting-outputs`, and watch mode is available through `build_runner watch --delete-conflicting-outputs`; - `dart format .`, `flutter analyze`, and available tests pass. - when applicable, the existing Turborepo detects the Flutter package, exposes package-local commands, and keeps root scripts as thin `turbo run` delegates; Report platform-specific permission configuration separately from Dart changes.