Flutter isar clean arch setup
Skill jahfaliabdulrahman-dev/hermes-skills/skills/flutter-isar-clean-arch-setup
Production-grade agent skills for Flutter, DevOps & AI Governance — forged in real projects, governed by constitution, verified on device.
npx -y skills add jahfaliabdulrahman-dev/hermes-skills --skill flutter-isar-clean-arch-setupAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 19 days oldThe repository was created 19 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Set up a Flutter + Isar + Riverpod Clean Architecture project. Covers manual file creation, schema generation, avoiding dependency hell, and structuring domain/data/presentation layers.
SKILL.md
22.4 KB, ~5.6k tokens by cl100k_base, as published. Nobody here has run it
Flutter + Isar + Clean Architecture Setup
Use this skill when scaffolding or repairing a Flutter project that uses Isar DB with Standard Riverpod and Clean Architecture layers.
Step-by-Step Setup
1. Platform Folders (Critical)
If files are created manually (no flutter create), the project lacks .dart_tool/package_config.json and platform folders. Always run:
flutter create . --project-name <name> --platforms=ios,macos
flutter pub get
Without this, every import shows uri_does_not_exist and build_runner fails.
2. Minimal pubspec.yaml
Avoid dependency hell. Only include what's needed:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
path_provider: ^2.1.4
path: ^1.9.0
intl: ^0.20.2 # Always match flutter_localizations pin
go_router: ^14.8.1 # For feature-first routing variant
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
build_runner: ^2.4.12
isar_generator: ^3.1.0+1
Pitfalls:
- Do NOT add
riverpod_generator,custom_lint,build_verify, orfreezed— they createanalyzerversion conflicts. intlversion is pinned byflutter_localizations— always check withflutter pub outdated.
3. Isar Model Rules
- Use ONLY
part 'model.g.dart'(generated by isar_generator). - Do NOT include
part '*.freezed.dart'unless Freezed is actually used. - Enum fields MUST have
@enumeratedannotation:@enumerated late MyEnum myField; - Id field:
Id id = Isar.autoIncrement; - Index:
@Index()above the field. - Constructor must use
this.id = Isar.autoIncrementwith a default value.
4. Generate Schemas
dart run build_runner build --delete-conflicting-outputs
If stale errors appear (cached), run:
flutter clean
flutter pub get
dart run build_runner build --delete-conflicting-outputs
5. Clean Architecture Structure
Variant A — Flat presentation (simpler):
lib/
├── domain/
│ └── repositories/ # Abstract interfaces only
│ ├── maintenance_repository.dart
│ └── service_task_repository.dart
├── data/
│ ├── models/ # Isar @collection classes
│ ├── datasources/local/ # isar_provider.dart
│ └── repositories/ # Implementation (Isar queries)
├── presentation/
│ └── providers/ # AsyncNotifier (not StateNotifier)
└── main.dart # init DB → ProviderScope override → runApp
Variant B — Feature-first + layers (CarSah File 07):
lib/
├── app/ # MaterialApp, router, theme, locale, bootstrap
│ ├── carsah_app.dart
│ ├── app_bootstrap.dart # Isar init, provider overrides
│ ├── app_router.dart # go_router definitions
│ ├── app_theme.dart # MD3 tokens
│ └── app_locale.dart # AR/EN delegates
├── core/
│ ├── constants/
│ ├── errors/ # Typed failure classes (sealed AppFailure)
│ ├── result/ # sealed Result<T> (Success | Failure)
│ ├── localization/ # Translation maps
│ ├── validation/ # Field validators
│ └── widgets/ # Shared widgets
├── data/
│ ├── local/
│ │ ├── isar_database.dart
│ │ ├── isar_collections/ # Isar @collection models
│ │ └── mappers/ # Isar model ↔ domain entity
│ └── repositories/ # Repository implementations
├── domain/
│ ├── entities/ # Domain entities
│ ├── enums/ # planLevel, taskStatus, fluidType, etc.
│ ├── value_objects/ # Odometer, Cost, SourceLabel
│ └── repositories/ # Repository contracts (interfaces)
├── features/
│ ├── vehicle_setup/
│ │ ├── presentation/ # Screens, widgets
│ │ └── application/ # Use cases
│ ├── dashboard/
│ │ ├── presentation/
│ │ └── application/
│ ├── history/
│ │ ├── presentation/
│ │ └── application/
│ ├── mechanic_card/
│ ├── audit/
│ └── settings/
├── shared/
│ ├── design_system/ # Colors, spacing, typography tokens
│ ├── source_labels/ # Source label enum + AR/EN display
│ ├── smart_standard_plan/ # Interval definitions, task codes
│ └── terminology/ # Workshop terms
└── main.dart # ProviderScope → CarSahApp
Rules for Variant B:
- Feature folders must NOT directly query Isar from UI.
- UI talks to providers → providers call use cases → use cases call repository interfaces.
- Repository implementations call Isar. Domain entities must not depend on Flutter widgets.
- No backend/api/auth/cloud/QR/VIN folders in MVP.
- Dependency direction:
Presentation → Application → Domain ← Data
Full File 07 reference: references/carsah-file07-architecture.md
6. Isar Provider Pattern
// lib/data/datasources/local/isar_provider.dart
final isarProvider = Provider<Isar>((ref) {
final existing = Isar.getInstance('db_name');
if (existing != null) return existing;
throw StateError('Database not initialized before runApp');
});
Future<Isar> initIsarDatabase() async {
final existing = Isar.getInstance('db_name');
if (existing != null) return existing;
final isar = await Isar.open(
[VehicleSchema, MaintenanceRecordSchema, ...],
directory: (await getApplicationDocumentsDirectory()).path,
name: 'db_name',
);
return isar;
}
In main.dart:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final isar = await initIsarDatabase();
runApp(ProviderScope(
overrides: [isarProvider.overrideWithValue(isar)],
child: const MyApp(),
));
}
7. Repository Implementation Rules
- Inject
Isarvia constructor:const MyRepoImpl(this.isar); - ALL writes must use
isar.writeTxn(() async { ... }). - Telemetry extraction MUST run inside the same
writeTxnas the primary save for atomicity. deleteAllrequiresList<Id>, NOTIterable<Id>→ call.toList().- Return
falseon catch blocks — never throw to UI.
8. Arabic Localization (Mandatory)
If you add Locale('ar', '') to supportedLocales, you MUST also add localizationsDelegates to MaterialApp or the app crashes at runtime with No MaterialLocalizations found:
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
9. Assets Must Exist AND Be Registered
rootBundle.loadString('assets/oem/file.json') will crash at runtime if:
- The file does not physically exist on disk.
- The path is not listed under
flutter.assetsin pubspec.yaml. Always create the asset file FIRST, then register it:
flutter:
assets:
- assets/oem/
Then run flutter pub get — the asset is bundled at build time.
10. Navigation Shell Pattern (Phase 5)
class HomeRootPage extends StatefulWidget {
@override
State<HomeRootPage> createState() => _HomeRootPageState();
}
class _HomeRootPageState extends State<HomeRootPage> {
int _currentIndex = 0;
late final List<Widget> _pages = [
const DashboardPage(),
const TasksPage(),
const HistoryPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _currentIndex, children: _pages),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) => setState(() => _currentIndex = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.checklist_outlined), selectedIcon: Icon(Icons.checklist), label: 'Tasks'),
NavigationDestination(icon: Icon(Icons.history_outlined), selectedIcon: Icon(Icons.history), label: 'History'),
],
),
);
}
}
IndexedStackpreserves state across tabs (Riverpod state is preserved).- No
Navigatorroutes needed for tab-based navigation.
11. AsyncNotifierProvider Gotcha
ref.watch(maintenanceProvider) returns AsyncValue<MaintenanceState>.
ref.watch(maintenanceProvider.notifier) returns MaintenanceNotifier (no .when()).
Never mix them — use .provider for state consumption, .provider.notifier for calling methods.
12. Stale Test File
flutter create . generates test/widget_test.dart referencing MyApp. Delete it immediately:
rm test/widget_test.dart
Pitfalls Summary
| Symptom | Cause | Fix |
|---|---|---|
uri_does_not_exist on all packages | No .dart_tool/package_config.json | flutter create . then flutter pub get |
Expected an identifier in build_runner | Orphaned part directives | Remove unused part 'x.freezed.dart' |
Enum property must be annotated with @enumerated | Isar 3.x requires it | Add @enumerated above enum field |
analyzer version conflict | custom_lint + isar_generator | Remove custom_lint, build_verify, freezed |
deleteAll type error | Iterable<Id> passed | Call .toList() on the map |
| Stale build_runner errors with wrong line numbers | Cached asset graph | flutter clean + flutter pub get |
CardTheme type error in ThemeData | cardTheme expects CardThemeData? not CardTheme | Use CardThemeData and AppBarThemeData |
withOpacity deprecated | Material 3 API change | Use color.withValues(alpha: 0.12) instead |
| Writing files to wrong project directory | User has multiple project roots | Always verify cwd with pwd before file writes |
No MaterialLocalizations found crash at runtime | Arabic locale without delegates | Add localizationsDelegates with 3 delegates |
rootBundle.loadString crash | Asset file missing or not registered | Create file + add to flutter.assets in pubspec |
.when() on notifier | Used .provider.notifier instead of .provider | Use .provider for AsyncValue, .notifier for methods |
MyApp not found in test | Stale widget_test.dart from flutter create | rm test/widget_test.dart |
| RTL visual corruption on Arabic devices | LTR text rendered on RTL layout | Pin locale in MaterialApp: locale: const Locale('en', '') |
RenderViewport expected RenderSliver crash | Card widget inside CustomScrollView | Use ListView for Card children, or wrap each Card in SliverToBoxAdapter |
state.overdueTasks.contains(task) always false | Isar model identity — different object instances | Compare by string field: .any((ot) => ot.taskKey == task.taskKey) |
DropdownButtonFormField deprecation warning | value parameter deprecated in Flutter 3.33+ | Use initialValue instead |
RenderViewport expected RenderSliver crash | Card inside CustomScrollView | Use ListView for cards, or SliverToBoxAdapter |
| RTL visual corruption on Arabic devices | LTR text rendered on RTL layout | Pin locale: locale: const Locale('en', '') |
| Save silently fails in dialog | _formKey.currentState?.validate() returns null | Use ! with try/catch, show SnackBar on failure |
| Telemetry extraction crashes app | _extractPartPricesSync called inside writeTxn but uses putSync | Isar putSync works inside transactions, but guard null/empty parts and zero cost |
13. Isar writeTxn Consistency Rule (Critical)
ALL operations inside isar.writeTxn(() async { ... }) MUST use await — never call putSync() or any synchronous mutation method. Mixing sync and async operations inside an async transaction causes Isar to throw silently, and if the exception is caught, the save appears to succeed but data is incomplete.
// WRONG — putSync inside async writeTxn causes a runtime throw
await isar.writeTxn(() async {
await isar.records.put(record);
_extractPricesSync(isar, record); // calls putSync -> CRASH
});
// CORRECT — all operations are async with await
await isar.writeTxn(() async {
await isar.records.put(record);
await _extractPricesAsync(isar, record); // calls await put() -> atomic
});
This bug is especially dangerous because:
- The exception is caught by a surrounding try/catch
- The UI gets a generic "save failed" error
- No data is partially saved — it's all-or-nothing
dart analyzecatches nothing — it's a runtime-only failure
Hard rule: If you're inside writeTxn(() async { }), EVERY Isar write must be await isar.xyz.put(...).
14. Batch Save Pattern (Multi-Select Checklist)
For forms where users log multiple items at once (e.g. maintenance batch), use a dynamic checklist instead of one-at-a-time entry:
// State: track which tasks are selected, one cost controller per task
final Set<String> _selectedTasks = {};
final Map<String, TextEditingController> _costControllers = {};
// When task is checked, show inline cost TextField
CheckboxListTile(
title: Text(task.displayNameEn), // Use human-readable name
value: _selectedTasks.contains(task.taskKey),
onChanged: (v) => setState(() =>
v == true ? _selectedTasks.add(task.taskKey) : _selectedTasks.remove(task.taskKey)
),
)
// If checked, show cost input below
if (_selectedTasks.contains(task.taskKey))
TextFormField(controller: _costControllers[task.taskKey], ...)
// Save: loop through selected tasks independently
for (final taskKey in _selectedTasks) {
final cost = double.tryParse(_sanitizeDigits(_costControllers[taskKey]!.text.trim())) ?? 0.0;
final record = MaintenanceRecord(
vehicleId: vehicleId,
serviceType: taskMap[taskKey], // displayNameEn, NOT taskKey!
totalCostSar: cost,
partsReplaced: [taskMap[taskKey]],
...
);
await ref.read(maintenanceProvider.notifier).addRecord(record);
await ref.read(serviceTaskProvider.notifier).markTaskCompleted(taskKey: taskKey, doneAtKm: odometer);
}
Key rules:
- Service type =
task.displayNameEn(readable), NOTtask.taskKey(programmatic). - Never hardcode service lists — read from the live provider.
- Track
savedCountvsfailedCount— report partial failures to user. - Each task gets its own
TextEditingControllerdisposed indispose().
15. Arabic Numeral Sanitization
On Arabic-locale devices, TextField input may contain Arabic-Indic digits (٠-٩, Unicode \u0660-\u0669) instead of ASCII (0-9). int.tryParse("١٢٣") returns null silently.
static final _arabicDigits = {
'\u0660': '0', '\u0661': '1', '\u0662': '2',
'\u0663': '3', '\u0664': '4', '\u0665': '5',
'\u0666': '6', '\u0667': '7', '\u0668': '8',
'\u0669': '9',
};
String _sanitizeDigits(String input) {
for (final entry in _arabicDigits.entries) {
input = input.replaceAll(entry.key, entry.value);
}
return input;
}
// Use before any parse:
final cost = double.tryParse(_sanitizeDigits(rawText.trim())) ?? 0.0;
final odometer = int.tryParse(_sanitizeDigits(rawText.trim().replaceAll(',', ''))) ?? 0;
Pitfalls Summary
| Symptom | Cause | Fix |
|---|---|---|
uri_does_not_exist on all packages | No .dart_tool/package_config.json | flutter create . then flutter pub get |
Expected an identifier in build_runner | Orphaned part directives | Remove unused part 'x.freezed.dart' |
Enum property must be annotated with @enumerated | Isar 3.x requires it | Add @enumerated above enum field |
analyzer version conflict | custom_lint + isar_generator | Remove custom_lint, build_verify, freezed |
deleteAll type error | Iterable<Id> passed | Call .toList() on the map |
| Stale build_runner errors with wrong line numbers | Cached asset graph | flutter clean + flutter pub get |
CardTheme type error in ThemeData | cardTheme expects CardThemeData? not CardTheme | Use CardThemeData and AppBarThemeData |
withOpacity deprecated | Material 3 API change | Use color.withValues(alpha: 0.12) instead |
| Writing files to wrong project directory | User has multiple project roots | Always verify cwd with pwd before file writes |
No MaterialLocalizations found crash at runtime | Arabic locale without delegates | Add localizationsDelegates with 3 delegates |
rootBundle.loadString crash | Asset file missing or not registered | Create file + add to flutter.assets in pubspec |
.when() on notifier | Used .provider.notifier instead of .provider | Use .provider for AsyncValue, .notifier for methods |
MyApp not found in test | Stale widget_test.dart from flutter create | rm test/widget_test.dart |
| RTL visual corruption on Arabic devices | LTR text rendered on RTL layout | Pin locale: locale: const Locale('en', '') |
RenderViewport expected RenderSliver crash | Card widget inside CustomScrollView | Use ListView for Card children, or wrap each Card in SliverToBoxAdapter |
state.overdueTasks.contains(task) always false | Isar model identity — different object instances | Compare by string field: .any((ot) => ot.taskKey == task.taskKey) |
DropdownButtonFormField deprecation warning | value parameter deprecated in Flutter 3.33+ | Use initialValue instead |
| Save silently fails (writeTxn) | putSync() called inside async writeTxn | Use await put(), never putSync() inside async txn |
Service type shows oil_change to user | Used task.taskKey instead of displayNameEn | Use task.displayNameEn as serviceType value |
int.tryParse returns null on valid input | Arabic-Indic numerals (٠-٩) in TextField | Sanitize with _sanitizeDigits() before parsing |
| Partial save not tracked | Only checked success boolean | Track savedCount and failedCount independently |
| App crashes immediately on Android launch (no splash) | namespace ≠ MainActivity.kt package → ClassNotFoundException | Unify all to same package (see §16c) |
| App crashes after showing splash | ProGuard stripped Isar adapter classes | isMinifyEnabled = false in release buildType (see §16a) |
| CI build passes, APK installs but crashes | isar_flutter_libs manifest has package attr | Add gradle.projectsEvaluated hook (see §16b) |
AAPT: error: resource android:attr/lStar not found during verifyReleaseResources | Isar 3.1.0 targets AGP 4.2 resources; lStar attribute unresolved under AGP 8.8+ | Defer Isar until a maintained AGP 8+ fork is available or use an alternative (see §16d) |
16. Android Release Build Rules (Isar-Specific)
These three rules are MANDATORY. Skipping any one causes either CI failure, crash-on-launch, or both.
16a. ProGuard — isMinifyEnabled = false
Isar generates adapter classes. ProGuard strips them because nothing in Java/Kotlin code directly references them. Isar.open() crashes at runtime.
// android/app/build.gradle.kts — release block
release {
isMinifyEnabled = false // REQUIRED for Isar
isShrinkResources = false
}
16b. AGP 8.8+ — isar_flutter_libs Manifest Fix
isar_flutter_libs ships an AndroidManifest with package="..." — rejected by AGP 8.8+ on GitHub Actions ubuntu-latest. Add to root android/build.gradle.kts:
gradle.projectsEvaluated {
subprojects {
if (name == "isar_flutter_libs") {
tasks.matching { it.name.contains("verifyReleaseResources") }.configureEach {
enabled = false
}
tasks.matching { it.name.startsWith("process") && it.name.contains("Manifest") }.configureEach {
doFirst {
val manifestFile = file("${project.projectDir}/src/main/AndroidManifest.xml")
if (manifestFile.exists()) {
val content = manifestFile.readText()
if (content.contains("package=")) {
manifestFile.writeText(content.replace(Regex("""package="[^"]*"\s*"""), ""))
}
}
}
}
}
}
}
16c. Namespace-Verification (Post-Swarm / Pre-Release)
AndroidManifest's android:name=".MainActivity" resolves relative to Gradle namespace. Mismatch → ClassNotFoundException → instant crash.
# These MUST match:
grep 'namespace' android/app/build.gradle.kts
grep '^package' android/app/src/main/kotlin/**/MainActivity.kt
Also verify these references are identical:
android/app/build.gradle.kts:namespace+applicationIdandroid/build.gradle.kts: subproject namespace injection (line ~30)android/app/proguard-rules.pro:-keep classline
16d. AGP Resource Incompatibility — lStar AAPT Error (Azal DEC-015)
Even with the manifest fix (§16b) applied, Isar 3.1.0 may still fail release builds with:
AAPT: error: resource android:attr/lStar not found
Root cause: Isar 3.1.0 was compiled against AGP 4.2. Its resources reference attributes (lStar was introduced in API 29) that AGP 8.8+ resolves differently. Disabling verifyReleaseResources (which §16b already does) masks this in some cases but the underlying resource incompatibility persists.
⚠️ The pub-cache anti-pattern: Patching ~/.pub-cache/hosted/pub.dev/isar_flutter_libs-3.1.0+1/android/build.gradle to add a namespace line makes the build work on ONE machine. This is NOT reproducible on any other machine, CI runner, or teammate's environment. Never claim a pub-cache patch as a "fix" — it's a local hack.
When to use Isar: Isar 3.1.0 is fundamentally incompatible with AGP 8.8+. Defer Isar until:
- The project actually needs local caching (offline storage, draft persistence).
- A maintained AGP 8+ compatible Isar fork exists, OR an alternative (
drift,hive_ce) has been evaluated.
Verification gate: If Isar is in pubspec.yaml, flutter build apk --release MUST succeed without any pub-cache patches. If it fails, the correct fix is removing Isar — not patching the cache.
Also update the AGP compat hook (§16b) description: even with verifyReleaseResources disabled and the package attribute stripped, the lStar resource error proves that the Isar 3.1.0 binary is incompatible with AGP 8.8+ at the resource level. The hooks in §16a/16b are necessary but NOT sufficient for a working release build with Isar 3.1.0.