fix: apply post-review fixes to Drift database layer
- Add @TableIndex on tasks.user_id and task_completions.user_id (FK scan perf) - Wrap incrementStreak in transaction to prevent lost-update race - Sentinel pattern in TaskEntity.copyWith and PetStateEntity.copyWith so nullable fields (deadline, snoozeUntil, photoPath, energyToday, etc.) can be explicitly cleared to null - Remove unused TaskCompletionEntity import from task_repository.dart - Harden completeTask: getSingleOrNull + ArgumentError instead of getSingle throw - Add doc comment to getTodayTasks clarifying no date filter (pending = today) - Regenerate app_database.g.dart (CREATE INDEX statements added) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1912,12 +1912,23 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
late final $PetStatesTable petStates = $PetStatesTable(this);
|
||||
late final $TaskCompletionsTable taskCompletions =
|
||||
$TaskCompletionsTable(this);
|
||||
late final Index idxTasksUserId = Index(
|
||||
'idx_tasks_user_id', 'CREATE INDEX idx_tasks_user_id ON tasks (user_id)');
|
||||
late final Index idxTaskCompletionsUserId = Index(
|
||||
'idx_task_completions_user_id',
|
||||
'CREATE INDEX idx_task_completions_user_id ON task_completions (user_id)');
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities =>
|
||||
[users, tasks, petStates, taskCompletions];
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
users,
|
||||
tasks,
|
||||
petStates,
|
||||
taskCompletions,
|
||||
idxTasksUserId,
|
||||
idxTaskCompletionsUserId
|
||||
];
|
||||
}
|
||||
|
||||
typedef $$UsersTableCreateCompanionBuilder = UsersCompanion Function({
|
||||
|
||||
@@ -17,6 +17,7 @@ class Users extends Table {
|
||||
}
|
||||
|
||||
/// 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)();
|
||||
@@ -61,6 +62,7 @@ class PetStates extends Table {
|
||||
}
|
||||
|
||||
/// 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)();
|
||||
|
||||
@@ -49,16 +49,18 @@ class PetRepository {
|
||||
}
|
||||
|
||||
Future<void> incrementStreak(String userId) async {
|
||||
final row = await (_db.select(_db.petStates)
|
||||
..where((t) => t.userId.equals(userId)))
|
||||
.getSingle();
|
||||
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()),
|
||||
));
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/entities/task_completion_entity.dart';
|
||||
import '../../domain/entities/task_entity.dart';
|
||||
import '../../domain/entities/task_status.dart';
|
||||
import '../db/app_database.dart';
|
||||
@@ -25,6 +24,9 @@ class TaskRepository {
|
||||
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) =>
|
||||
@@ -67,23 +69,27 @@ class TaskRepository {
|
||||
await (_db.delete(_db.tasks)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
/// Marks a task as done and creates a [TaskCompletionEntity] log entry.
|
||||
/// 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)))
|
||||
.getSingle();
|
||||
.getSingleOrNull();
|
||||
|
||||
if (task == null) {
|
||||
throw ArgumentError.value(taskId, 'taskId', 'Task not found');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Update task status to done.
|
||||
await (_db.update(_db.tasks)..where((t) => t.id.equals(taskId)))
|
||||
.write(TasksCompanion(
|
||||
status: Value(TaskStatus.done.name),
|
||||
updatedAt: Value(now),
|
||||
));
|
||||
|
||||
// Log the completion.
|
||||
await _db.into(_db.taskCompletions).insert(TaskCompletionsCompanion(
|
||||
id: Value(_uuid.v4()),
|
||||
userId: Value(task.userId),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Sentinel distinguishes "not provided" from "explicitly null" in copyWith.
|
||||
const _unset = Object();
|
||||
|
||||
/// Pure domain entity for pet state.
|
||||
class PetStateEntity {
|
||||
final String id;
|
||||
@@ -23,8 +26,8 @@ class PetStateEntity {
|
||||
PetStateEntity copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
int? energyToday,
|
||||
DateTime? energyDate,
|
||||
Object? energyToday = _unset,
|
||||
Object? energyDate = _unset,
|
||||
int? streakDays,
|
||||
int? totalTasksDone,
|
||||
String? unlockedItems,
|
||||
@@ -33,8 +36,12 @@ class PetStateEntity {
|
||||
return PetStateEntity(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
energyToday: energyToday ?? this.energyToday,
|
||||
energyDate: energyDate ?? this.energyDate,
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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;
|
||||
@@ -40,35 +43,40 @@ class TaskEntity {
|
||||
String? id,
|
||||
String? userId,
|
||||
String? title,
|
||||
String? description,
|
||||
Object? description = _unset,
|
||||
int? energyLevel,
|
||||
TaskStatus? status,
|
||||
bool? isSelfCare,
|
||||
DateTime? deadline,
|
||||
String? photoPath,
|
||||
DateTime? snoozeUntil,
|
||||
Object? deadline = _unset,
|
||||
Object? photoPath = _unset,
|
||||
Object? snoozeUntil = _unset,
|
||||
int? sortOrder,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? syncedAt,
|
||||
String? localId,
|
||||
Object? syncedAt = _unset,
|
||||
Object? localId = _unset,
|
||||
}) {
|
||||
return TaskEntity(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
description:
|
||||
identical(description, _unset) ? this.description : description as String?,
|
||||
energyLevel: energyLevel ?? this.energyLevel,
|
||||
status: status ?? this.status,
|
||||
isSelfCare: isSelfCare ?? this.isSelfCare,
|
||||
deadline: deadline ?? this.deadline,
|
||||
photoPath: photoPath ?? this.photoPath,
|
||||
snoozeUntil: snoozeUntil ?? this.snoozeUntil,
|
||||
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: syncedAt ?? this.syncedAt,
|
||||
localId: localId ?? this.localId,
|
||||
syncedAt:
|
||||
identical(syncedAt, _unset) ? this.syncedAt : syncedAt as DateTime?,
|
||||
localId: identical(localId, _unset) ? this.localId : localId as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user