Skip to main content
← back

flutter app setup

flutter · dart · riverpod · go_router · drift

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.

llms.txt

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:

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:

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

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:

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:

final class ExampleDto {
  const ExampleDto({required this.id, required this.title});

  final String id;
  final String title;

  factory ExampleDto.fromJson(Map<String, dynamic> 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<Either<Failure, ExampleDto>> fetchExample() {
    return _api.sendRequest(
      "/example",
      method: MethodType.get,
      decode: (data) => ExampleDto.fromJson(data! as Map<String, dynamic>),
    );
  }
}

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:

abstract interface class ExampleRepository {
  Future<Either<Failure, Example>> load();
  Future<Either<Failure, Example>> refresh();
}

final class ExampleRepositoryImpl implements ExampleRepository {
  ExampleRepositoryImpl(this._service);

  final ExampleApiService _service;
  Example? _cached;

  @override
  Future<Either<Failure, Example>> load() async {
    final cached = _cached;
    if (cached != null) return Right(cached);
    return refresh();
  }

  @override
  Future<Either<Failure, Example>> 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:

@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<Example?> build() async {
    final result = await ref.watch(exampleRepositoryProvider).load();
    return result.fold((failure) => throw failure, (value) => value);
  }

  Future<void> 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:

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:

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:

{
  "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:

{
  "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:

{
  "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:

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:

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:

class AppRouter {
  static final navigatorKey = GlobalKey<NavigatorState>();

  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<String>, 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:

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:

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:

enum AppPermissionStatus { granted, denied, permanentlyDenied, restricted }

abstract interface class PermissionService {
  Future<AppPermissionStatus> statusForNotifications();
  Future<AppPermissionStatus> requestNotifications();
  Future<bool> 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<L, R> instead of nullable tuples or throwing expected API failures through feature code:

sealed class Either<L, R> {
  const Either();

  T fold<T>(T Function(L left) onLeft, T Function(R right) onRight);
}

final class Left<L, R> extends Either<L, R> {
  const Left(this.value);
  final L value;

  @override
  T fold<T>(T Function(L left) onLeft, T Function(R right) onRight) =>
      onLeft(value);
}

final class Right<L, R> extends Either<L, R> {
  const Right(this.value);
  final R value;

  @override
  T fold<T>(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:

Future<Either<Failure, T>> sendRequest<T>(
  String path, {
  required MethodType method,
  required T Function(Object? data) decode,
  RequestType requestType = RequestType.json,
  Object? body,
  MultipartBody? multipartBody,
  Map<String, dynamic>? queryParameters,
  Map<String, dynamic>? 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:

final body = MultipartBody(
  fields: {"title": title},
  files: [ApiFile(field: "image", path: imagePath, filename: "cover.jpg")],
);
final result = await api.sendRequest<UploadDto>(
  "/uploads",
  method: MethodType.post,
  requestType: RequestType.multipart,
  multipartBody: body,
  decode: (data) => UploadDto.fromJson(data! as Map<String, dynamic>),
);

For SSE, use the same Dio client with ResponseType.stream and return a typed Stream<Either<Failure, SseEvent>>. 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<Failure, T> 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.