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.dartfiles 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.dartThe dependency direction is one-way:
presentation → feature/domain → services → coreServices 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.dartThe dependency flow is:
ExampleView
↓ commands/state
ExampleViewModel
↓
LoadExample (only when useful)
↓
ExampleRepository
↓
ExampleApiService → ApiClientmodel 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.8Use 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:
- environment/configuration;
KvStore;- theme mode;
- the Drift database;
- auth/session restoration if the app has auth;
- logger subscription;
- 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/OverlayEntryimplementation 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-outputsDuring 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-outputsIf 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
Failuresubclasses; - 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
UnauthorizedFailureand invoke an injected session-expired callback; - expose request cancellation, connect/send/receive timeouts, and optional progress callbacks;
- return decoded DTOs, never
dynamicvalues, 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
printcalls; - 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 throughbuild_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 rundelegates;
Report platform-specific permission configuration separately from Dart changes.