docs: complete project research
This commit is contained in:
389
.planning/research/ARCHITECTURE.md
Normal file
389
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** Local-first Flutter household chore management app
|
||||
**Researched:** 2026-03-15
|
||||
**Confidence:** HIGH (cross-verified: official Flutter docs, CodeWithAndrea, Drift official docs, multiple community templates)
|
||||
|
||||
## Standard Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
|
||||
│ │ Screens │ │ Widgets │ │ Notifiers │ │ Providers│ │
|
||||
│ │(RoomList, │ │(TaskCard, │ │(AsyncNotif │ │(wiring │ │
|
||||
│ │ DailyPlan) │ │ RoomCard) │ │ -erProvider│ │ DI) │ │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └────┬─────┘ │
|
||||
└────────┼───────────────┼───────────────┼───────────────┼────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DOMAIN LAYER │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ Entities │ │ Repository │ │ Use Cases │ │
|
||||
│ │(Room,Task, │ │ Interfaces │ │(CompleteTask, GetDailyPlan,│ │
|
||||
│ │ Completion)│ │(abstract) │ │ ScheduleNextDue) │ │
|
||||
│ └────────────┘ └────────────┘ └────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DATA LAYER │
|
||||
│ ┌───────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||
│ │ Drift DAOs │ │ Repository │ │ Notification Data │ │
|
||||
│ │(RoomDao, │ │ Impls │ │ Source │ │
|
||||
│ │ TaskDao, │ │(RoomRepo, │ │(flutter_local_ │ │
|
||||
│ │ CompletionDao)│ │ TaskRepo) │ │ notifications) │ │
|
||||
│ └───────┬───────┘ └──────┬───────┘ └─────────┬─────────┘ │
|
||||
└──────────┼─────────────────┼─────────────────────┼─────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ INFRASTRUCTURE LAYER │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ AppDatabase (Drift / SQLite) │ │
|
||||
│ │ Tables: rooms | tasks | task_completions | templates │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Typical Implementation |
|
||||
|-----------|----------------|------------------------|
|
||||
| Screen | Renders a full page; reads Riverpod providers | `ConsumerWidget` or `ConsumerStatefulWidget` |
|
||||
| Widget | Reusable UI component; dumb or lightly reactive | Stateless/Consumer widget |
|
||||
| AsyncNotifier | Manages async state for a feature; exposes methods for mutations | `AsyncNotifier<T>` subclass with `@riverpod` annotation |
|
||||
| StreamProvider | Exposes a reactive Drift `.watch()` stream to the UI | `@riverpod Stream<List<T>> ...` |
|
||||
| Entity | Immutable business object; no framework deps | Plain Dart class, often `@freezed` |
|
||||
| Repository interface | Defines data contract; domain stays unaware of SQLite | Abstract Dart class |
|
||||
| Repository impl | Implements interface using Drift DAOs; translates DB rows to entities | Concrete class in `data/` |
|
||||
| DAO | Type-safe SQL operations for one table group | `@DriftAccessor` class |
|
||||
| AppDatabase | Drift database root; holds all tables and registers DAOs | `@DriftDatabase` extending `_$AppDatabase` |
|
||||
| NotificationService | Schedules/cancels local OS notifications | Wraps `flutter_local_notifications`; injected via Riverpod |
|
||||
|
||||
## Recommended Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── database/
|
||||
│ │ ├── app_database.dart # @DriftDatabase — root DB class
|
||||
│ │ ├── app_database.g.dart # Generated by build_runner
|
||||
│ │ └── database_provider.dart # Riverpod Provider<AppDatabase>
|
||||
│ ├── notifications/
|
||||
│ │ ├── notification_service.dart # Wraps flutter_local_notifications
|
||||
│ │ └── notification_provider.dart # Riverpod provider for service
|
||||
│ ├── theme/
|
||||
│ │ └── app_theme.dart # Material 3 color scheme, typography
|
||||
│ └── l10n/
|
||||
│ └── app_de.arb # German strings
|
||||
│
|
||||
├── features/
|
||||
│ ├── rooms/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── room_dao.dart # @DriftAccessor for rooms table
|
||||
│ │ │ ├── room_dao.g.dart
|
||||
│ │ │ └── room_repository_impl.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── room.dart # Entity (Freezed)
|
||||
│ │ │ └── room_repository.dart # Abstract interface
|
||||
│ │ └── presentation/
|
||||
│ │ ├── rooms_screen.dart
|
||||
│ │ ├── room_detail_screen.dart
|
||||
│ │ ├── rooms_provider.dart # StreamProvider watching rooms
|
||||
│ │ └── widgets/
|
||||
│ │ └── room_card.dart
|
||||
│ │
|
||||
│ ├── tasks/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── task_dao.dart
|
||||
│ │ │ ├── task_dao.g.dart
|
||||
│ │ │ └── task_repository_impl.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── task.dart # Entity (Freezed)
|
||||
│ │ │ ├── task_repository.dart
|
||||
│ │ │ └── scheduling_service.dart # due-date recurrence logic
|
||||
│ │ └── presentation/
|
||||
│ │ ├── task_list_screen.dart
|
||||
│ │ ├── task_form_screen.dart
|
||||
│ │ ├── tasks_provider.dart
|
||||
│ │ └── widgets/
|
||||
│ │ └── task_card.dart
|
||||
│ │
|
||||
│ ├── daily_plan/
|
||||
│ │ ├── domain/
|
||||
│ │ │ └── daily_plan_service.dart # Query: overdue/today/upcoming
|
||||
│ │ └── presentation/
|
||||
│ │ ├── daily_plan_screen.dart
|
||||
│ │ └── daily_plan_provider.dart
|
||||
│ │
|
||||
│ ├── completions/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── completion_dao.dart
|
||||
│ │ │ └── completion_repository_impl.dart
|
||||
│ │ └── domain/
|
||||
│ │ ├── completion.dart
|
||||
│ │ └── completion_repository.dart
|
||||
│ │
|
||||
│ └── templates/
|
||||
│ ├── data/
|
||||
│ │ └── bundled_templates.dart # Static template data (German)
|
||||
│ └── domain/
|
||||
│ └── task_template.dart
|
||||
│
|
||||
└── main.dart # ProviderScope root; bootstrap DB
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
|
||||
- **`core/`:** Houses exactly two cross-feature concerns — the shared `AppDatabase` instance and the `NotificationService`. These must be singletons shared across features, so they belong outside any feature folder. Theme and l10n are also here.
|
||||
- **`features/`:** Each feature is fully self-contained with its own data/domain/presentation split. This lets you develop and test `tasks/` without touching `rooms/`, and makes the build order obvious — domain defines contracts first, data implements them, presentation consumes.
|
||||
- **`data/` layer per feature:** Drift DAOs live here. One DAO per table group (rooms, tasks, completions). The repository impl translates between Drift-generated row objects and domain entities.
|
||||
- **No `usecases/` folder initially:** For an app this size, use-case logic (e.g., "complete a task and compute next due date") lives in domain service classes or directly in `AsyncNotifier.build/methods`. Full use-case classes are appropriate at larger scale but add boilerplate overhead at MVP stage.
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: Reactive Drift Streams via StreamProvider
|
||||
|
||||
**What:** Drift's `.watch()` returns a `Stream<List<T>>` that emits on every relevant DB change. Expose this directly via a Riverpod `StreamProvider` so the UI rebuilds automatically without polling or manual refresh calls.
|
||||
|
||||
**When to use:** All read operations — room list, task list, daily plan. Anything the user needs to see stay current.
|
||||
|
||||
**Trade-offs:** Simple, reactive, zero caching complexity. Works perfectly for purely local data. Would require adjustment if a sync layer were added later.
|
||||
|
||||
**Example:**
|
||||
```dart
|
||||
// features/tasks/presentation/tasks_provider.dart
|
||||
@riverpod
|
||||
Stream<List<Task>> tasksByRoom(TasksByRoomRef ref, int roomId) {
|
||||
final dao = ref.watch(databaseProvider).taskDao;
|
||||
return dao.watchTasksByRoom(roomId).map(
|
||||
(rows) => rows.map(Task.fromRow).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// features/tasks/data/task_dao.dart
|
||||
@DriftAccessor(tables: [Tasks])
|
||||
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
||||
TaskDao(super.db);
|
||||
|
||||
Stream<List<TaskData>> watchTasksByRoom(int roomId) =>
|
||||
(select(tasks)..where((t) => t.roomId.equals(roomId))
|
||||
..orderBy([(t) => OrderingTerm(expression: t.dueDate)]))
|
||||
.watch();
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: AsyncNotifier for Mutations
|
||||
|
||||
**What:** Mutations (create, update, delete, complete) go through an `AsyncNotifier`. The notifier holds no derived state — it calls the repository and lets the `StreamProvider` propagate the DB change automatically.
|
||||
|
||||
**When to use:** Any write operation — creating a room, completing a task, editing task metadata.
|
||||
|
||||
**Trade-offs:** Clean separation between reads (StreamProvider) and writes (AsyncNotifier). The notifier is thin; no manual state synchronization needed because Drift streams handle it.
|
||||
|
||||
**Example:**
|
||||
```dart
|
||||
@riverpod
|
||||
class TaskNotifier extends _$TaskNotifier {
|
||||
@override
|
||||
FutureOr<void> build() {}
|
||||
|
||||
Future<void> completeTask(int taskId) async {
|
||||
final repo = ref.read(taskRepositoryProvider);
|
||||
await repo.completeTask(taskId); // writes completion + updates dueDate
|
||||
// No setState needed — Stream from watchTasksByRoom emits automatically
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Single AppDatabase Singleton via Riverpod
|
||||
|
||||
**What:** One `Provider<AppDatabase>` at the application root. All DAOs are accessed as getters on this instance (`db.taskDao`, `db.roomDao`). The provider registers `ref.onDispose(db.close)` to clean up.
|
||||
|
||||
**When to use:** Always. Multiple DB instances will corrupt SQLite state.
|
||||
|
||||
**Trade-offs:** Simple and correct. The singleton approach works well for a single-device, offline app. Would need adjustment only if the schema were split across multiple databases (rare for apps this size).
|
||||
|
||||
**Example:**
|
||||
```dart
|
||||
// core/database/database_provider.dart
|
||||
@riverpod
|
||||
AppDatabase appDatabase(AppDatabaseRef ref) {
|
||||
final db = AppDatabase();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Read Flow (Reactive)
|
||||
|
||||
```
|
||||
User opens screen
|
||||
↓
|
||||
ConsumerWidget watches StreamProvider
|
||||
↓
|
||||
StreamProvider subscribes to DAO .watch() stream
|
||||
↓
|
||||
Drift executes SQL SELECT, returns Stream<List<Row>>
|
||||
↓
|
||||
Row objects mapped to domain Entities in provider
|
||||
↓
|
||||
Widget renders from AsyncValue<List<Entity>>
|
||||
↓
|
||||
[Any DB write elsewhere]
|
||||
↓
|
||||
Drift detects table change, emits new list to all watchers
|
||||
↓
|
||||
Widget rebuilds automatically
|
||||
```
|
||||
|
||||
### Write Flow (Mutation)
|
||||
|
||||
```
|
||||
User taps "Mark Done"
|
||||
↓
|
||||
Widget calls ref.read(taskNotifierProvider.notifier).completeTask(id)
|
||||
↓
|
||||
AsyncNotifier calls TaskRepository.completeTask(id)
|
||||
↓
|
||||
Repository impl calls TaskDao.insertCompletion() + TaskDao.updateNextDue()
|
||||
↓
|
||||
Drift writes to SQLite
|
||||
↓
|
||||
All active .watch() streams for affected tables emit updated data
|
||||
↓
|
||||
Daily plan StreamProvider rebuilds automatically
|
||||
↓
|
||||
UI reflects completion + new due date
|
||||
```
|
||||
|
||||
### Notification Scheduling Flow
|
||||
|
||||
```
|
||||
App startup / task completion
|
||||
↓
|
||||
NotificationService.scheduleDaily(summaryTime)
|
||||
↓
|
||||
flutter_local_notifications schedules OS alarm
|
||||
↓
|
||||
(No Riverpod state involved — fire-and-forget side effect)
|
||||
↓
|
||||
OS delivers notification at scheduled time
|
||||
↓
|
||||
User taps notification → app opens to DailyPlanScreen
|
||||
```
|
||||
|
||||
### Key Data Flows Summary
|
||||
|
||||
1. **Room list:** `AppDatabase.roomDao.watchAllRooms()` → `StreamProvider<List<Room>>` → `RoomsScreen`
|
||||
2. **Daily plan:** `AppDatabase.taskDao.watchDueTasks(today)` → `StreamProvider<DailyPlan>` → `DailyPlanScreen` (sections: overdue / today / upcoming)
|
||||
3. **Task completion:** `TaskNotifierProvider.completeTask()` → `TaskDao.insertCompletion() + updateNextDue()` → all watchers update
|
||||
4. **Cleanliness indicator:** Derived in provider from overdue task count per room — computed from `watchTasksByRoom` stream, no separate table needed
|
||||
5. **Template seeding:** On first launch, `main.dart` checks `AppDatabase` for empty tables and seeds from static `bundled_templates.dart` within a single transaction
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
This is a single-device offline app. Scaling means "works without degradation as data grows over months/years," not multi-user or distributed concerns.
|
||||
|
||||
| Scale | Architecture Adjustments |
|
||||
|-------|--------------------------|
|
||||
| < 500 tasks | No optimization needed — Drift handles it trivially |
|
||||
| 500–5,000 tasks | Add indexes on `due_date` and `room_id` in Drift table definitions (add from the start to avoid a migration later) |
|
||||
| 5,000+ tasks | Paginate daily plan queries; add `LIMIT/OFFSET` or cursor-based pagination in DAOs |
|
||||
| Task history growth | Completion log can grow unbounded. Archive completions older than 1 year in a separate table or apply `DELETE WHERE created_at < X` on startup |
|
||||
|
||||
### Scaling Priorities
|
||||
|
||||
1. **First bottleneck:** Drift `.watch()` queries that return very large lists — fix with `LIMIT` and proper indexes
|
||||
2. **Second bottleneck:** Notification scheduling on a large task set — fix by only scheduling the next N upcoming tasks rather than all tasks
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: Accessing AppDatabase directly in widgets
|
||||
|
||||
**What people do:** `ref.watch(appDatabaseProvider).taskDao.watchAllTasks()` inside a `build()` method.
|
||||
|
||||
**Why it's wrong:** Creates tight coupling between UI and infrastructure. Bypasses the repository pattern. Makes the widget untestable without a real database. Breaks the dependency rule — presentation should not know about Drift.
|
||||
|
||||
**Do this instead:** Always go through a StreamProvider or AsyncNotifier that wraps the DAO call. Widgets only `ref.watch(tasksByRoomProvider(roomId))`.
|
||||
|
||||
### Anti-Pattern 2: Multiple AppDatabase instances
|
||||
|
||||
**What people do:** Calling `AppDatabase()` in multiple places — e.g., inside each DAO file or feature-level provider.
|
||||
|
||||
**Why it's wrong:** SQLite with multiple connections to the same file causes locking errors and data corruption. Drift will throw at runtime.
|
||||
|
||||
**Do this instead:** One `@riverpod AppDatabase appDatabase(...)` provider at the app root. All DAOs are accessed as getters on `ref.watch(appDatabaseProvider)`.
|
||||
|
||||
### Anti-Pattern 3: Storing computed state in the database
|
||||
|
||||
**What people do:** Adding a `cleanliness_score` column that is updated on every task completion.
|
||||
|
||||
**Why it's wrong:** Derived data that can be computed from existing rows should never be stored. It creates consistency bugs (score can drift out of sync with actual task states). It adds unnecessary write operations.
|
||||
|
||||
**Do this instead:** Compute cleanliness score in a Riverpod provider that derives from the `watchTasksByRoom` stream. Pure computation, always consistent.
|
||||
|
||||
### Anti-Pattern 4: Using `ref.read` in widget `build()` for streams
|
||||
|
||||
**What people do:** `final tasks = ref.read(tasksProvider)` inside `build()` to avoid rebuilds.
|
||||
|
||||
**Why it's wrong:** `ref.read` does not subscribe — the widget will not update when the stream emits new data. This defeats the entire point of reactive streams.
|
||||
|
||||
**Do this instead:** Always `ref.watch(tasksProvider)` in `build()`. To avoid excessive rebuilds, use `ref.select()` or split into smaller widgets.
|
||||
|
||||
### Anti-Pattern 5: Business logic in Drift DAOs
|
||||
|
||||
**What people do:** Putting "complete task and compute next due date" entirely inside the DAO.
|
||||
|
||||
**Why it's wrong:** DAOs should be thin SQL wrappers. Business rules (recurrence calculation, overdue logic) belong in the domain layer — `SchedulingService` or within `TaskRepository`. DAOs that contain business logic are harder to test and break the Clean Architecture dependency rule.
|
||||
|
||||
**Do this instead:** DAO handles SQL. Repository impl calls DAO + domain service. Domain service contains recurrence math.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Internal Boundaries
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|---------------|-------|
|
||||
| Presentation -> Domain | Riverpod providers (read/watch repository interfaces) | Presentation never imports from `data/` directly |
|
||||
| Domain -> Data | Repository interface (abstract class) | Domain defines interface; data implements it |
|
||||
| Data -> Infrastructure | Drift DAO methods | DAO is the only thing that knows about table/column names |
|
||||
| Notifications -> Domain | `NotificationService` injected via Riverpod; called from `TaskNotifier` after writes | Notifications are a side effect, not part of state |
|
||||
| Templates -> Database | Seeded once on first launch via `AppDatabase.transaction()` | Static data; not a runtime concern |
|
||||
|
||||
### Build Order (Dependency Sequence)
|
||||
|
||||
Components must be built in dependency order. Later steps depend on earlier ones:
|
||||
|
||||
1. **AppDatabase schema** — tables, migrations; everything else depends on this
|
||||
2. **DAOs** — generated from table definitions; needed by repository impls
|
||||
3. **Domain entities + repository interfaces** — pure Dart; no framework deps
|
||||
4. **Repository implementations** — depend on DAOs and domain entities
|
||||
5. **Riverpod providers** — wire database and repositories; depend on both
|
||||
6. **Domain services** — scheduling, daily plan computation; depend on entities
|
||||
7. **Feature notifiers** — depend on repository providers and domain services
|
||||
8. **Screens and widgets** — depend on notifiers and stream providers
|
||||
9. **NotificationService** — can be built any time after step 1; integrated as a side effect in step 7
|
||||
10. **Template seeding** — runs at app startup after AppDatabase is available
|
||||
|
||||
## Sources
|
||||
|
||||
- [Flutter App Architecture with Riverpod: An Introduction — CodeWithAndrea](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/)
|
||||
- [Persistent storage architecture: SQL — Flutter official docs](https://docs.flutter.dev/app-architecture/design-patterns/sql)
|
||||
- [Offline-first support — Flutter official docs](https://docs.flutter.dev/app-architecture/design-patterns/offline-first)
|
||||
- [DAOs — Drift official documentation](https://drift.simonbinder.eu/dart_api/daos/)
|
||||
- [Building Local-First Flutter Apps with Riverpod, Drift, and PowerSync — Dinko Marinac](https://dinkomarinac.dev/blog/building-local-first-flutter-apps-with-riverpod-drift-and-powersync/)
|
||||
- [Flutter Riverpod Clean Architecture Template — ssoad (GitHub)](https://github.com/ssoad/flutter_riverpod_clean_architecture)
|
||||
- [Integrating Local Databases in Flutter Using Drift — vibe-studio.ai](https://vibe-studio.ai/insights/integrating-local-databases-in-flutter-using-drift)
|
||||
- [State Management with Riverpod — sample_drift_app (DeepWiki)](https://deepwiki.com/h-enoki/sample_drift_app/5.1-state-management-with-riverpod)
|
||||
|
||||
---
|
||||
|
||||
*Architecture research for: Local-first Flutter household chore management app (HouseHoldKeaper)*
|
||||
*Researched: 2026-03-15*
|
||||
Reference in New Issue
Block a user