# Flutter Isar Clean Arch Setup

> 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: `jahfaliabdulrahman-dev/flutter-isar-clean-arch-setup` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jahfaliabdulrahman-dev/flutter-isar-clean-arch-setup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jahfaliabdulrahman-dev/flutter-isar-clean-arch-setup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jahfaliabdulrahman-dev (https://skillmd.com/u/jahfaliabdulrahman-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jahfaliabdulrahman-dev/flutter-isar-clean-arch-setup

---


# 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:
```bash
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:
```yaml
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`, or `freezed` — they create `analyzer` version conflicts.
- `intl` version is pinned by `flutter_localizations` — always check with `flutter 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 `@enumerated` annotation:
  ```dart
  @enumerated
  late MyEnum myField;
  ```
- Id field: `Id id = Isar.autoIncrement;`
- Index: `@Index()` above the field.
- Constructor must use `this.id = Isar.autoIncrement` with a default value.

### 4. Generate Schemas
```bash
dart run build_runner build --delete-conflicting-outputs
```
If stale errors appear (cached), run:
```bash
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
```dart
// 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:**
```dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final isar = await initIsarDatabase();
  runApp(ProviderScope(
    overrides: [isarProvider.overrideWithValue(isar)],
    child: const MyApp(),
  ));
}
```

### 7. Repository Implementation Rules
- Inject `Isar` via constructor: `const MyRepoImpl(this.isar);`
- ALL writes must use `isar.writeTxn(() async { ... })`.
- Telemetry extraction MUST run inside the same `writeTxn` as the primary save for atomicity.
- `deleteAll` requires `List<Id>`, NOT `Iterable<Id>` → call `.toList()`.
- Return `false` on 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`:
```dart
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.assets` in pubspec.yaml.
Always create the asset file FIRST, then register it:
```yaml
flutter:
  assets:
    - assets/oem/
```
Then run `flutter pub get` — the asset is bundled at build time.

## 10. Navigation Shell Pattern (Phase 5)
```dart
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'),
        ],
      ),
    );
  }
}
```
- `IndexedStack` preserves state across tabs (Riverpod state is preserved).
- No `Navigator` routes 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:
```bash
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.

```dart
// 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 analyze` catches 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:

```dart
// 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), NOT `task.taskKey` (programmatic).
- Never hardcode service lists — read from the live provider.
- Track `savedCount` vs `failedCount` — report partial failures to user.
- Each task gets its own `TextEditingController` disposed in `dispose()`.

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

```dart
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.

```kotlin
// 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`:

```kotlin
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-Generation / Pre-Release)

AndroidManifest's `android:name=".MainActivity"` resolves relative to Gradle `namespace`. Mismatch → `ClassNotFoundException` → instant crash.

```bash
# 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` + `applicationId`
- `android/build.gradle.kts`: subproject namespace injection (line ~30)
- `android/app/proguard-rules.pro`: `-keep class` line

### 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:
1. The project actually needs local caching (offline storage, draft persistence).
2. 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.
