Merge NDM-App-190: Drift Database — tables, DAOs, repositories

This commit is contained in:
TrochtaOndrej
2026-03-18 04:35:06 +01:00
13 changed files with 4163 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
# Drift Database Layer
**Branch:** `NDM-App-190-drift-database-tables-daos-repositories`
**Commits:** `bb0dcdf`, `8256e57`
## Overview
Implements the local SQLite persistence layer using Drift 2.x. The database is the offline-first source of truth. All data access goes through typed repository classes that map between Drift-generated data classes and pure domain entities.
---
## Files Created
### `lib/data/models/tables.dart`
Four Drift table definitions:
| Table | Description |
|---|---|
| `Users` | Device identity, pet selection, pet name |
| `Tasks` | Core task items with sync tracking columns |
| `PetStates` | Energy, streak, total completions, unlocked items |
| `TaskCompletions` | Append-only completion log for rewards/analytics |
Key design details:
- `Tasks` has `@TableIndex` on `userId` (also on `TaskCompletions`).
- `localId` on `Tasks` is a nullable unique column for external sync identity.
- `PetStates.userId` is unique — one pet state row per user.
- Enums (`petType`, `status`) are stored as `text` strings; conversion is done in the repository layer.
### `lib/data/db/app_database.dart`
`@DriftDatabase` class wiring all four tables. Uses `driftDatabase(name: 'kittie_mobile_db')` from `drift_flutter` (the correct API for `drift_flutter ^0.2.0`). `schemaVersion = 1`.
Generated part file: `lib/data/db/app_database.g.dart`.
### `lib/data/repositories/task_repository.dart`
```dart
TaskRepository(AppDatabase db)
```
| Method | Description |
|---|---|
| `getAllTasks(userId)` | All tasks ordered by `sortOrder` |
| `getTodayTasks(userId)` | Pending tasks only, ordered by `sortOrder` |
| `getTaskById(id)` | Single task or null |
| `watchTasks(userId)` | Reactive stream of all tasks |
| `insertTask(entity)` | Insert from domain entity |
| `updateTask(entity)` | Full-row update by id |
| `deleteTask(id)` | Hard delete |
| `completeTask(taskId)` | Transaction: sets `status=done`, inserts `TaskCompletion`; throws `ArgumentError` if task not found |
### `lib/data/repositories/pet_repository.dart`
```dart
PetRepository(AppDatabase db)
```
| Method | Description |
|---|---|
| `getPetState(userId)` | Single pet state or null |
| `watchPetState(userId)` | Reactive stream (nullable) |
| `insertPetState(entity)` | First-time insert |
| `updatePetState(entity)` | Full-row update |
| `setEnergyToday(userId, energy)` | Partial update: energyToday + energyDate + updatedAt |
| `incrementStreak(userId)` | Transaction: read current streak, write +1 |
### `lib/data/repositories/user_repository.dart`
```dart
UserRepository(AppDatabase db)
```
| Method | Description |
|---|---|
| `getCurrentUser()` | First row (limit 1) or null; single-user device model |
| `createUser(id, deviceId, name, petType, petName)` | Named params for clarity |
| `updateUser(entity)` | Full-row update |
---
## Domain Entities (`lib/domain/entities/`)
Pure Dart classes — no Drift imports. Used across layers.
| File | Contents |
|---|---|
| `task_entity.dart` | `TaskEntity` with `copyWith` (sentinel `_unset` pattern for nullable fields) |
| `task_status.dart` | `TaskStatus` enum: `pending`, `done`, `snoozed`, `someday`, `archived` |
| `pet_state_entity.dart` | `PetStateEntity` |
| `pet_type.dart` | `PetType` enum: `dog`, `cat`, `capybara` |
| `user_entity.dart` | `UserEntity` |
| `task_completion_entity.dart` | `TaskCompletionEntity` |
Each enum has a `fromString(String)` factory that parses DB text values.
---
## Patterns
### Entity ↔ Drift mapping
Every repository has private `_toEntity(Row)` and `_toCompanion(Entity)` methods. No Drift types leak outside the repository.
### Transactions
`completeTask` and `incrementStreak` wrap multi-step writes in `_db.transaction(...)`.
### `copyWith` for nullable fields
`TaskEntity.copyWith` uses a private `const _unset = Object()` sentinel so callers can explicitly pass `null` to clear a nullable field without ambiguity.
---
## Tests (`test/data/repositories/`)
30 unit tests using `NativeDatabase.memory()` (real in-memory SQLite, no mocking).
| File | Tests |
|---|---|
| `user_repository_test.dart` | 6 |
| `task_repository_test.dart` | 12 |
| `pet_repository_test.dart` | 12 |
Each test group creates a fresh `AppDatabase` in `setUp` and closes it in `tearDown`.
---
## Code Generation
After any change to `tables.dart` or `app_database.dart`, regenerate with:
```bash
dart run build_runner build --delete-conflicting-outputs
```
+19
View File
@@ -0,0 +1,19 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import '../models/tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Users, Tasks, PetStates, TaskCompletions])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor])
: super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(name: 'kittie_mobile_db');
}
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import 'package:drift/drift.dart';
/// User profiles — device identity and pet selection.
class Users extends Table {
TextColumn get id => text()();
TextColumn get deviceId => text().unique()();
TextColumn get name => text().withDefault(const Constant('Friend'))();
TextColumn get petType => text().withDefault(const Constant('cat'))();
TextColumn get petName => text().withDefault(const Constant('Yoshi'))();
DateTimeColumn get createdAt =>
dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt =>
dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
/// Tasks — the core task items with sync support.
@TableIndex(name: 'idx_tasks_user_id', columns: {#userId})
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get userId => text().references(Users, #id)();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
IntColumn get energyLevel => integer().withDefault(const Constant(2))();
TextColumn get status =>
text().withDefault(const Constant('pending'))();
BoolColumn get isSelfCare =>
boolean().withDefault(const Constant(false))();
DateTimeColumn get deadline => dateTime().nullable()();
TextColumn get photoPath => text().nullable()();
DateTimeColumn get snoozeUntil => dateTime().nullable()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt =>
dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt =>
dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get syncedAt => dateTime().nullable()();
TextColumn get localId => text().nullable().unique()();
@override
Set<Column> get primaryKey => {id};
}
/// Pet state — energy, streaks, and unlocked items per user.
class PetStates extends Table {
TextColumn get id => text()();
TextColumn get userId => text().unique().references(Users, #id)();
IntColumn get energyToday => integer().nullable()();
DateTimeColumn get energyDate => dateTime().nullable()();
IntColumn get streakDays => integer().withDefault(const Constant(0))();
IntColumn get totalTasksDone =>
integer().withDefault(const Constant(0))();
TextColumn get unlockedItems =>
text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt =>
dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
/// Task completion log — records each completion for rewards and analytics.
@TableIndex(name: 'idx_task_completions_user_id', columns: {#userId})
class TaskCompletions extends Table {
TextColumn get id => text()();
TextColumn get userId => text().references(Users, #id)();
TextColumn get taskId => text().nullable()();
TextColumn get title => text()();
IntColumn get energyLevel => integer()();
DateTimeColumn get completedAt =>
dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:drift/drift.dart';
import '../../domain/entities/pet_state_entity.dart';
import '../db/app_database.dart';
/// Data access layer for pet state.
class PetRepository {
final AppDatabase _db;
PetRepository(this._db);
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
Future<PetStateEntity?> getPetState(String userId) async {
final row = await (_db.select(_db.petStates)
..where((t) => t.userId.equals(userId)))
.getSingleOrNull();
return row == null ? null : _toEntity(row);
}
Stream<PetStateEntity?> watchPetState(String userId) {
return (_db.select(_db.petStates)
..where((t) => t.userId.equals(userId)))
.watchSingleOrNull()
.map((row) => row == null ? null : _toEntity(row));
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
Future<void> updatePetState(PetStateEntity entity) async {
await (_db.update(_db.petStates)
..where((t) => t.id.equals(entity.id)))
.write(_toCompanion(entity));
}
Future<void> setEnergyToday(String userId, int energy) async {
final now = DateTime.now();
await (_db.update(_db.petStates)
..where((t) => t.userId.equals(userId)))
.write(PetStatesCompanion(
energyToday: Value(energy),
energyDate: Value(now),
updatedAt: Value(now),
));
}
Future<void> incrementStreak(String userId) async {
await _db.transaction(() async {
final row = await (_db.select(_db.petStates)
..where((t) => t.userId.equals(userId)))
.getSingle();
await (_db.update(_db.petStates)
..where((t) => t.userId.equals(userId)))
.write(PetStatesCompanion(
streakDays: Value(row.streakDays + 1),
updatedAt: Value(DateTime.now()),
));
});
}
Future<void> insertPetState(PetStateEntity entity) async {
await _db.into(_db.petStates).insert(_toCompanion(entity));
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
PetStateEntity _toEntity(PetState row) {
return PetStateEntity(
id: row.id,
userId: row.userId,
energyToday: row.energyToday,
energyDate: row.energyDate,
streakDays: row.streakDays,
totalTasksDone: row.totalTasksDone,
unlockedItems: row.unlockedItems,
updatedAt: row.updatedAt,
);
}
PetStatesCompanion _toCompanion(PetStateEntity e) {
return PetStatesCompanion(
id: Value(e.id),
userId: Value(e.userId),
energyToday: Value(e.energyToday),
energyDate: Value(e.energyDate),
streakDays: Value(e.streakDays),
totalTasksDone: Value(e.totalTasksDone),
unlockedItems: Value(e.unlockedItems),
updatedAt: Value(e.updatedAt),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/entities/task_status.dart';
import '../db/app_database.dart';
/// Data access layer for tasks and task completions.
class TaskRepository {
final AppDatabase _db;
static const _uuid = Uuid();
TaskRepository(this._db);
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
Future<List<TaskEntity>> getAllTasks(String userId) async {
final rows = await (_db.select(_db.tasks)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
return rows.map(_toEntity).toList();
}
/// Returns all [TaskStatus.pending] tasks for [userId], ordered by sortOrder.
/// "Today's tasks" in this app means all pending tasks — there is no
/// date-based filter; energy level and pet mood drive prioritisation instead.
Future<List<TaskEntity>> getTodayTasks(String userId) async {
final rows = await (_db.select(_db.tasks)
..where((t) =>
t.userId.equals(userId) &
t.status.equals(TaskStatus.pending.name))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
return rows.map(_toEntity).toList();
}
Future<TaskEntity?> getTaskById(String id) async {
final row = await (_db.select(_db.tasks)
..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _toEntity(row);
}
Stream<List<TaskEntity>> watchTasks(String userId) {
return (_db.select(_db.tasks)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.watch()
.map((rows) => rows.map(_toEntity).toList());
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
Future<void> insertTask(TaskEntity entity) async {
await _db.into(_db.tasks).insert(_toCompanion(entity));
}
Future<void> updateTask(TaskEntity entity) async {
await (_db.update(_db.tasks)..where((t) => t.id.equals(entity.id)))
.write(_toCompanion(entity));
}
Future<void> deleteTask(String id) async {
await (_db.delete(_db.tasks)..where((t) => t.id.equals(id))).go();
}
/// Marks a task as done and creates a completion log entry atomically.
///
/// Throws [ArgumentError] if [taskId] does not exist.
Future<void> completeTask(String taskId) async {
await _db.transaction(() async {
final task = await (_db.select(_db.tasks)
..where((t) => t.id.equals(taskId)))
.getSingleOrNull();
if (task == null) {
throw ArgumentError.value(taskId, 'taskId', 'Task not found');
}
final now = DateTime.now();
await (_db.update(_db.tasks)..where((t) => t.id.equals(taskId)))
.write(TasksCompanion(
status: Value(TaskStatus.done.name),
updatedAt: Value(now),
));
await _db.into(_db.taskCompletions).insert(TaskCompletionsCompanion(
id: Value(_uuid.v4()),
userId: Value(task.userId),
taskId: Value(taskId),
title: Value(task.title),
energyLevel: Value(task.energyLevel),
completedAt: Value(now),
));
});
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
TaskEntity _toEntity(Task row) {
return TaskEntity(
id: row.id,
userId: row.userId,
title: row.title,
description: row.description,
energyLevel: row.energyLevel,
status: TaskStatus.fromString(row.status),
isSelfCare: row.isSelfCare,
deadline: row.deadline,
photoPath: row.photoPath,
snoozeUntil: row.snoozeUntil,
sortOrder: row.sortOrder,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
syncedAt: row.syncedAt,
localId: row.localId,
);
}
TasksCompanion _toCompanion(TaskEntity e) {
return TasksCompanion(
id: Value(e.id),
userId: Value(e.userId),
title: Value(e.title),
description: Value(e.description),
energyLevel: Value(e.energyLevel),
status: Value(e.status.name),
isSelfCare: Value(e.isSelfCare),
deadline: Value(e.deadline),
photoPath: Value(e.photoPath),
snoozeUntil: Value(e.snoozeUntil),
sortOrder: Value(e.sortOrder),
createdAt: Value(e.createdAt),
updatedAt: Value(e.updatedAt),
syncedAt: Value(e.syncedAt),
localId: Value(e.localId),
);
}
}
@@ -0,0 +1,78 @@
import 'package:drift/drift.dart';
import '../../domain/entities/pet_type.dart';
import '../../domain/entities/user_entity.dart';
import '../db/app_database.dart';
/// Data access layer for user profiles.
class UserRepository {
final AppDatabase _db;
UserRepository(this._db);
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/// Returns the first (and typically only) user row, or `null`.
Future<UserEntity?> getCurrentUser() async {
final row = await (_db.select(_db.users)..limit(1)).getSingleOrNull();
return row == null ? null : _toEntity(row);
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
Future<void> createUser({
required String id,
required String deviceId,
required String name,
required PetType petType,
required String petName,
}) async {
final now = DateTime.now();
await _db.into(_db.users).insert(UsersCompanion(
id: Value(id),
deviceId: Value(deviceId),
name: Value(name),
petType: Value(petType.name),
petName: Value(petName),
createdAt: Value(now),
updatedAt: Value(now),
));
}
Future<void> updateUser(UserEntity entity) async {
await (_db.update(_db.users)..where((t) => t.id.equals(entity.id)))
.write(_toCompanion(entity));
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
UserEntity _toEntity(User row) {
return UserEntity(
id: row.id,
deviceId: row.deviceId,
name: row.name,
petType: PetType.fromString(row.petType),
petName: row.petName,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
);
}
UsersCompanion _toCompanion(UserEntity e) {
return UsersCompanion(
id: Value(e.id),
deviceId: Value(e.deviceId),
name: Value(e.name),
petType: Value(e.petType.name),
petName: Value(e.petName),
createdAt: Value(e.createdAt),
updatedAt: Value(e.updatedAt),
);
}
}
+51
View File
@@ -0,0 +1,51 @@
// Sentinel distinguishes "not provided" from "explicitly null" in copyWith.
const _unset = Object();
/// Pure domain entity for pet state.
class PetStateEntity {
final String id;
final String userId;
final int? energyToday;
final DateTime? energyDate;
final int streakDays;
final int totalTasksDone;
final String unlockedItems;
final DateTime updatedAt;
const PetStateEntity({
required this.id,
required this.userId,
this.energyToday,
this.energyDate,
this.streakDays = 0,
this.totalTasksDone = 0,
this.unlockedItems = '',
required this.updatedAt,
});
PetStateEntity copyWith({
String? id,
String? userId,
Object? energyToday = _unset,
Object? energyDate = _unset,
int? streakDays,
int? totalTasksDone,
String? unlockedItems,
DateTime? updatedAt,
}) {
return PetStateEntity(
id: id ?? this.id,
userId: userId ?? this.userId,
energyToday: identical(energyToday, _unset)
? this.energyToday
: energyToday as int?,
energyDate: identical(energyDate, _unset)
? this.energyDate
: energyDate as DateTime?,
streakDays: streakDays ?? this.streakDays,
totalTasksDone: totalTasksDone ?? this.totalTasksDone,
unlockedItems: unlockedItems ?? this.unlockedItems,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/// Available pet companions.
enum PetType {
dog,
cat,
capybara;
/// Parse from database string value.
static PetType fromString(String value) =>
PetType.values.firstWhere((e) => e.name == value);
}
@@ -0,0 +1,18 @@
/// Pure domain entity for a task completion log entry.
class TaskCompletionEntity {
final String id;
final String userId;
final String? taskId;
final String title;
final int energyLevel;
final DateTime completedAt;
const TaskCompletionEntity({
required this.id,
required this.userId,
this.taskId,
required this.title,
required this.energyLevel,
required this.completedAt,
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'task_status.dart';
// Sentinel distinguishes "not provided" from "explicitly null" in copyWith.
const _unset = Object();
/// Pure domain entity for a task.
class TaskEntity {
final String id;
final String userId;
final String title;
final String? description;
final int energyLevel;
final TaskStatus status;
final bool isSelfCare;
final DateTime? deadline;
final String? photoPath;
final DateTime? snoozeUntil;
final int sortOrder;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? syncedAt;
final String? localId;
const TaskEntity({
required this.id,
required this.userId,
required this.title,
this.description,
this.energyLevel = 2,
this.status = TaskStatus.pending,
this.isSelfCare = false,
this.deadline,
this.photoPath,
this.snoozeUntil,
this.sortOrder = 0,
required this.createdAt,
required this.updatedAt,
this.syncedAt,
this.localId,
});
TaskEntity copyWith({
String? id,
String? userId,
String? title,
Object? description = _unset,
int? energyLevel,
TaskStatus? status,
bool? isSelfCare,
Object? deadline = _unset,
Object? photoPath = _unset,
Object? snoozeUntil = _unset,
int? sortOrder,
DateTime? createdAt,
DateTime? updatedAt,
Object? syncedAt = _unset,
Object? localId = _unset,
}) {
return TaskEntity(
id: id ?? this.id,
userId: userId ?? this.userId,
title: title ?? this.title,
description:
identical(description, _unset) ? this.description : description as String?,
energyLevel: energyLevel ?? this.energyLevel,
status: status ?? this.status,
isSelfCare: isSelfCare ?? this.isSelfCare,
deadline: identical(deadline, _unset) ? this.deadline : deadline as DateTime?,
photoPath:
identical(photoPath, _unset) ? this.photoPath : photoPath as String?,
snoozeUntil: identical(snoozeUntil, _unset)
? this.snoozeUntil
: snoozeUntil as DateTime?,
sortOrder: sortOrder ?? this.sortOrder,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
syncedAt:
identical(syncedAt, _unset) ? this.syncedAt : syncedAt as DateTime?,
localId: identical(localId, _unset) ? this.localId : localId as String?,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
/// All possible states a task can be in.
enum TaskStatus {
pending,
done,
snoozed,
someday,
archived;
/// Parse from database string value.
static TaskStatus fromString(String value) =>
TaskStatus.values.firstWhere((e) => e.name == value);
}
+42
View File
@@ -0,0 +1,42 @@
import 'pet_type.dart';
/// Pure domain entity for a user profile.
class UserEntity {
final String id;
final String deviceId;
final String name;
final PetType petType;
final String petName;
final DateTime createdAt;
final DateTime updatedAt;
const UserEntity({
required this.id,
required this.deviceId,
required this.name,
required this.petType,
required this.petName,
required this.createdAt,
required this.updatedAt,
});
UserEntity copyWith({
String? id,
String? deviceId,
String? name,
PetType? petType,
String? petName,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return UserEntity(
id: id ?? this.id,
deviceId: deviceId ?? this.deviceId,
name: name ?? this.name,
petType: petType ?? this.petType,
petName: petName ?? this.petName,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}