Guidance for AI coding assistants (and humans pairing with them) working in the Trakli Flutter app. The goal of this file is simple: let you implement features quickly without breaking the parts of the codebase you didn't touch.
Read this before generating code. When in doubt, copy the pattern of the nearest existing feature rather than inventing a new one.
This file applies to the whole repo. Two companion docs go deeper:
- doc/bloc_cubit_patterns.md — state management patterns (read before touching any Cubit).
- doc/drift_sync_crash_reporting.md — sync error reporting.
- CONTRIBUTING.md — environment setup, commit/style rules.
Trakli is an offline-first personal finance tracker (income/expenses, wallets, budgets, transfers, groups, categories, parties). It is a Flutter app built with Clean Architecture and a local-first sync model: the UI always reads/writes the local Drift (SQLite) database, and changes are synced to the server in the background via drift_sync_core.
- Flutter: pinned to
3.38.9via FVM (see .fvmrc). Usefvm flutter ...if FVM is installed. - Dart SDK:
>=3.4.3 <4.0.0.
Code lives in three layers under lib/. Dependencies point inward only. Breaking this is the #1 way AI-generated code corrupts the codebase.
presentation/ ──depends on──> domain/ <──implemented by── data/
(UI + Cubits) (pure contracts) (Drift, Dio, sync)
│ │
└──────────────── never imports data/ directly ───────────┘
entities/— immutable@freezeddomain models (e.g.CategoryEntity). UI and use cases speak in entities.repositories/— abstract repository interfaces returningFuture<Either<Failure, T>>/Stream<Either<Failure, T>>.usecases/— one class per operation, implementingUseCase<T, Params>orStreamUseCase<T, Params>(seelib/core/usecases/usecase.dart). Use cases hold no logic beyond delegating to a repository.
database/tables/— Drift table definitions.database/app_database.dart— the database + generatedapp_database.g.dart.datasources/<feature>/—*_local_datasource.dart(Drift queries) and*_remote_datasource.dart(Dio calls), plusdtos/for wire models.repositories/*_impl.dart— implement the domain repository interface; orchestrate local datasource + sync.mappers/— convert Drift row types ↔ domain entities (CategoryMapper.toDomain).sync/*_sync_handler.dart—SyncTypeHandlersubclasses that drivedrift_sync_core.
cubit/—Cubit+@freezedstate. Cubits depend only on use cases, never on repositories or datasources directly.- screens +
widgets/.
Errors (error/), DI base types, network, sync wiring, constants, extensions, utils. Anything shared across features.
Rules of thumb:
- A Cubit imports use cases. It must not import anything from
lib/data/. - A use case imports a domain repository interface. It must not import an
*_implor a datasource. - Drift row classes (e.g.
db.Category) stay in the data layer. Convert to entities with a mapper before returning to domain. - If you need a new operation, add it to the repository interface first, then implement it.
To add a feature end-to-end, follow the existing Category slice as the canonical template. Touch files in this order:
- Entity —
lib/domain/entities/<x>_entity.dart(@freezed). - Repository interface —
lib/domain/repositories/<x>_repository.dart(returnsEither<Failure, T>). - Use case(s) —
lib/domain/usecases/<x>/<verb>_<x>_usecase.dart,@injectable, one per operation, with a...Paramsclass. - Drift table (if persisted) —
lib/data/database/tables/<x>.dartusingwith SyncTable; register it inapp_database.dart. - Local datasource —
lib/data/datasources/<x>/<x>_local_datasource.dart(abstract +@Injectable(as: ...)impl). - Remote datasource + DTO (if synced) —
..._remote_datasource.dart,dtos/<x>_dto.dart. - Sync handler (if synced) —
lib/data/sync/<x>_sync_handler.dartextendingSyncTypeHandler. - Mapper —
lib/data/mappers/<x>_mapper.dart. - Repository impl —
lib/data/repositories/<x>_repository_impl.dart,@LazySingleton(as: <X>Repository). - Cubit + state —
lib/presentation/<x>/cubit/,@injectable. - Wire the Cubit into the tree — add a
BlocProvider(create: (_) => getIt<XCubit>())inlib/presentation/app_widget.dart(or the feature's local provider) as appropriate. - Run codegen (Section 5) and
flutter analyze.
Adding a field to an existing feature (vs. a whole new slice)? Trace the same chain, and don't forget the easy-to-miss edit points: the use-case
...Paramsclasses (e.g.AddCategoryUseCaseParams), the mapper, and every method of the sync handler (see §8). A field added to the entity/table but missed in any link is silently dropped at that boundary.
Reuse existing UI helpers instead of re-implementing them:
showSnackBar,showDeleteConfirmationDialog,showConfirmationDialog(lib/presentation/utils/helpers.dart,dialogs.dart), andAppNavigator(lib/presentation/utils/app_navigator.dart).
The app uses flutter_bloc Cubits (no events) with freezed immutable state. Non-negotiable rules:
- State is a
@freezedclass with a.initial()factory and arequired Failure failurefield. - Never mutate state — always
emit(state.copyWith(...)). - Reset the failure at the start of every operation:
emit(state.copyWith(isLoading: true, failure: const Failure.none())). - Per-operation loading flags:
isLoading,isSaving,isDeleting, etc. - Fold the
Eitherresult; store failures in state, never throw out of a Cubit. - Cancel stream subscriptions in
close()— forgetting this is a memory leak. - Side effects (navigation, snackbars) go in
BlocListenerwithlistenWhen; reactive UI goes inBlocBuilderwithbuildWhen.
See the doc for the full Add/Update/Delete/listen patterns and anti-patterns.
Large parts of the codebase are generated. Editing generated files by hand will be overwritten and will break builds.
Never edit files ending in: .g.dart, .freezed.dart, injection.config.dart, assets.gen.dart, codegen_loader.g.dart, locale_keys.g.dart, app_database.g.dart.
Instead, edit the source annotation file and regenerate. (Flutter/Dart are fvm-pinned here — see §1. If you run tools through FVM, prefix every command below with fvm, e.g. fvm dart run build_runner ..., fvm flutter pub run .... The bare forms work only if the pinned SDK is your active dart/flutter.)
# freezed / json_serializable / injectable / drift (run after changing any annotated source)
dart run build_runner build --delete-conflicting-outputs
# assets (after adding/removing an image or SVG under assets/)
dart run flutter_gen_runner # or: fluttergen -c pubspec.yaml
# localization (after editing assets/translations/*.json)
flutter pub run easy_localization:generate -S "assets/translations/" -O "lib/gen/translations" -o "codegen_loader.g.dart" -f keysReminders:
- After adding
@injectable/@LazySingleton/@Injectableto a class, run build_runner soinjection.config.dartpicks it up. A new use case/repository/Cubit that isn't registered will fail at runtime with aget_itlookup error. - After changing a Drift table, run build_runner and add a schema migration (see Section 8).
- The container is
getIt(lib/di/injection.dart);configureDependencies(env)is called frombootstrap.dart. - Annotate registrable classes:
- Repositories:
@LazySingleton(as: <X>Repository). - Datasources / sync handlers:
@Injectable(as: ...)/@lazySingleton. - Use cases & Cubits:
@injectable.
- Repositories:
- Resolve dependencies via constructor injection. Only resolve via
getIt<T>()directly at composition roots (e.g.BlocProvider(create: (_) => getIt<XCubit>())). - Manual registrations and
ignoreUnregisteredTypeslive inlib/di/injection.dart— touch with care.
- Repository methods return
Future<Either<Failure, T>>. Wrap datasource calls inRepositoryErrorHandler.handleApiCall(() async { ... })(lib/core/error/repository_error_handler.dart) — it maps exceptions → typedFailures. Failureis a freezed union (lib/core/error/failures/failures.dart):serverError,networkError,validationError,unauthorizedError,duplicate,notFound,none, etc.- Throw the matching exception from datasources (e.g.
DuplicateException) — don't return failures from datasources. - In the UI, use
state.failure.hasErrorandstate.failure.customMessage(already localized). Pass theFailurestraight toshowSnackBar(message: state.failure).
This is the most fragile subsystem. Breaking it causes silent data loss or sync loops.
- Every synced table mixes in
SyncTable(lib/data/database/tables/sync_table.dart). That mixin supplies all sync columns and their server-side@JsonKeynames —clientId(the primary key, locally generated),id(nullable server id),userId,rev,createdAt/updatedAt/deletedAt,lastSyncedAt. Just writeclass Foo extends Table with SyncTable; never redeclare those columns or change the primary key. - Two infrastructure tables back the sync engine — do not treat them as feature tables or touch them unless you are working on sync itself:
LocalChanges(tables/local_changes.dart) — the outbox queue of pending local mutations (entityType/entityId/rev/data/error/...), keyed by(entityId, entityType).SyncMetadata(tables/sync_meta_data.dart) — per-entity-typelastSyncedAtcursor.AppDatabaseimplements thedrift_sync_corehooks against these (getPendingLocalChanges,insertLocalChange,concludeLocalChange,getLocalSyncMetadata, …). Don't bypass them with ad-hoc reads/writes.
- Client IDs: generate local rows'
clientIdwithgenerateDeviceScopedId()(lib/core/utils/id_helper.dart). Never reuse or hand-craft IDs. - Repository write methods write locally first, then fire-and-forget the sync via
unawaited(post(...))/put(...)/delete(...)(provided bySyncEntityRepository). Keep this pattern — the UI must not wait on the network. - Each synced entity needs a
*SyncHandlerimplementing marshal/unmarshal, local upsert/delete, and id resolution. Mirror an existing handler exactly.- When you add a field to a synced entity, audit the WHOLE handler — not just
upsertLocal. Some handler methods hand-build the data class instead of returning the Drift row (e.g.CategorySyncHandler.getLocalByServerIddoesreturn Category(id: …, name: …, …)). A hand-built constructor silently drops any column you don't list, so add your new field there too.marshal/unmarshalthat delegate to the generatedtoJson()/fromJson()pick up new columns automatically after codegen; hand-built constructors do not.
- When you add a field to a synced entity, audit the WHOLE handler — not just
- Sync triggers (lifecycle/connectivity/5-min timer) are wired in
bootstrap.dart. Don't add ad-hoc sync calls.
The app migrates with drift's stepByStep strategy. The moving parts:
lib/data/database/app_database.dart— theschemaVersiongetter and the hand-writtenfrom<N>To<M>callbacks (theMigrationsextension at the bottom; see the existingfrom4To5).lib/data/database/app_database.steps.dart— generated per-version schema snapshots (// GENERATED BY drift_dev, DO NOT MODIFY).drift_schemas/default/drift_schema_v<N>.json— exported schema per version.test/drift/default/generated/schema_v<N>.dart— generated snapshots used bytest/drift/default/migration_test.dart.
defaultis the database name frombuild.yaml(databases: { default: ... }). drift's defaultschema_dir(drift_schemas/) andtest_dir(test/drift/) already match this repo, so no extra config is needed.
The tool scaffolds; you (with AI help) write the actual logic. drift_dev make-migrations regenerates the schema snapshots, the .steps.dart file, and the test files — and adds an empty from<N>To<M> callback. It cannot infer your intent, so you fill in each callback body by hand (createTable / addColumn / data backfills).
Workflow when you add/alter/remove a table or column:
- Edit the table under
lib/data/database/tables/(register new tables in the@DriftDatabase(tables: [...])list inapp_database.dart). - Bump
schemaVersion(e.g.5→6) inapp_database.dart. - Regenerate code + migration scaffolding:
dart run build_runner build --delete-conflicting-outputs # app_database.g.dart, freezed, etc. dart run drift_dev make-migrations # schema json + .steps.dart + test snapshots
- Fill in the new
from<old>To<new>callback in theMigrationsextension inapp_database.dart, using theschema.*snapshot — never the live tables. Example:from5To6: (Migrator m, Schema6 schema) async { await m.createTable(schema.myNewTable); // await m.addColumn(schema.transactions, schema.transactions.newColumn); },
- Run
flutter test(the migration tests live intest/drift/default/migration_test.dart). The simple-migration suite currently shipsskip: true, and the data-integrity tests are TODO templates — for column type/constraint changes (not pure additions), fill in a data-integrity test so the migration is proven not to lose data.
Never change a table without bumping the version and adding a step callback — existing users' on-device databases will fail to open or silently lose data. Also note build.yaml sets store_date_time_values_as_text: true, so DateTime columns persist as ISO text.
Localization
- No hardcoded user-facing strings. Add the key to every
assets/translations/<lang>.json(en, de, es, fr, it, ru), regenerate (Section 5), then useLocaleKeys.my_key.tr().
Assets
- Reference via generated
Assets(Assets.images...), not raw string paths. Regenerate after adding files.
Theming (light + dark — every screen must work in both)
- Themes are defined in
lib/presentation/utils/theme.dartasAppTheme.lightTheme/AppTheme.darkTheme, applied inMaterialAppand switched byThemeCubit(mode is persisted in Config underConfigConstants.theme). Component styling (buttons, cards, inputs, date/time pickers) is themed centrally there — prefer relying on the theme over per-widget overrides. - Use semantic design tokens, not raw colors. Colors live in the
AppTonestheme extension (lib/presentation/utils/design_tokens.dart) and are read via thecontext.tonesgetter — e.g.context.tones.brand.accent,context.tones.income,context.tones.textMuted,context.tones.tone(...). These resolve to the right light/dark value automatically. - Do NOT hardcode
Color(0xFF…)in widgets, and avoid the legacy flat globals inlib/presentation/utils/colors.dartandlib/core/constants/colors.dart— they predateAppTonesand are being migrated away from. New or changed UI should usecontext.tones(andTheme.of(context)for text/component styles). Hardcoded colors break dark mode. ⚠️ Some existing screens predateAppTonesand still hardcode colors (e.g. the Category add form hardcodesColor(0xFFEB5757)as its accent). When §3 says "copy the nearest feature," copy its structure, not its color usage — replace any hardcodedColor(...)with the matchingcontext.tonestoken as you go.- Responsive sizing: the app uses
flutter_screenutil(design size 390×844, set inbootstrap.dart). Express font/icon sizes and dimensions with.sp(and.w/.h/.rwhere appropriate) — e.g.fontSize: 13.sp,size: 16.sp— not raw logical pixels.
Three flavors, each with its own entrypoint and Firebase config:
flutter run --flavor development --target lib/main_development.dart
flutter run --flavor staging --target lib/main_staging.dart
flutter run --flavor production --target lib/main_production.dart(VS Code launch configs mobile:development|staging|production are in .vscode/launch.json.)
flutter pub get # after editing pubspec.yaml
flutter analyze # must pass — enforced by the pre-commit hook
flutter test # must pass — enforced by the pre-push hook
dart format . # formatting standardTests live in test/ (unit/, widget/, drift/). Use mocktail and bloc_test.
- Branch off
dev(the main branch). Don't commit directly todev. - Conventional Commits, sentence-case subject, ≤72 chars (enforced by commitlint). Allowed types:
feat, fix, docs, style, refactor, test, chore, build, ci, enh, enhance, tweak, imp, improve. - Hooks (Husky) run automatically:
- pre-commit:
lint_staged+flutter analyze(+ GitHub Actions validation). Commit is blocked if analysis fails. - pre-push:
flutter test.
- pre-commit:
- Don't bypass hooks (
--no-verify) to land code.
- Import
lib/data/...from a Cubit, screen, or use case. - Edit a generated file (
*.g.dart,*.freezed.dart,injection.config.dart, etc.) by hand. - Add an
@injectable/repository/Cubit without running build_runner (→ runtimeget_itfailure). - Mutate Cubit state instead of
copyWith, or forget to resetfailure/ cancel a subscription inclose(). - Return Drift row types from the domain layer instead of mapping to entities.
- Change a Drift table without bumping the schema version + adding a migration + updating migration tests.
- Hardcode a user-facing string, a raw asset path, or a
Color(0xFF…)/ raw pixel size (useLocaleKeys,Assets,context.tones,.sp) — and verify the screen still works in dark mode. - Read/write the sync infrastructure tables (
LocalChanges,SyncMetadata) directly, or redeclareSyncTablecolumns on a feature table. - Make the UI block on a network/sync call (writes are local-first; sync is fire-and-forget).
- Leave
flutter analyzeorflutter testfailing.
When unsure how something should look, find the equivalent in an existing feature (Category, Wallet, Budget) and match it.