Skip to content

Commit 1137554

Browse files
authored
Merge pull request #75 from sohamM97/code-review
Round 11 code review: sync/state-sync fixes + coverage
2 parents 4ff56fe + 62b9c83 commit 1137554

19 files changed

Lines changed: 1551 additions & 125 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ flutter test --coverage # with coverage
3131

3232
- **App shell:** `AppShell` in `main.dart` hosts a 3-tab `PageView` (Today, Starred, All Tasks) with `NavigationBar`. Completed tasks are accessed via archive icon in each tab's AppBar. Tabs use `AutomaticKeepAliveClientMixin` to preserve state across switches. `TaskProvider` manages a navigation stack (`_parentStack`) for drill-down within All Tasks.
3333
- **DB schema:** Core tables: `tasks`, `task_relationships` (parent_id, child_id), `task_dependencies` (blocker relationships), `todays_five_state` (daily pin state), `task_schedules` (recurring), `sync_queue` (pending mutations). Multi-parent DAG with cycle prevention via recursive CTE (`DatabaseHelper.hasPath()`).
34-
- **DB migrations:** Sequential `onUpgrade` in `DatabaseHelper` (currently at v22, constant `_dbVersion`). Foreign keys via `PRAGMA foreign_keys = ON`. **NEVER modify a released migration** — if a previous version shipped with migration N, new DDL must go in migration N+1 or later. Retroactive changes to released migrations are silently skipped on devices already past that version. Also update `_validateBackup` version check (uses `_dbVersion` automatically) and the backup version test.
34+
- **DB migrations:** Sequential `onUpgrade` in `DatabaseHelper` (currently at v23, constant `_dbVersion`). Foreign keys via `PRAGMA foreign_keys = ON`. **NEVER modify a released migration** — if a previous version shipped with migration N, new DDL must go in migration N+1 or later. Retroactive changes to released migrations are silently skipped on devices already past that version. Also update `_validateBackup` version check (uses `_dbVersion` automatically) and the backup version test.
3535
- **Cloud sync:** Optional Google Sign-In + Firestore via REST APIs (no Firebase SDK). `SyncService` orchestrates push/pull; mutations queued in `sync_queue` table and debounced. **Mutation flow:** `TaskProvider.onMutation` callback → `SyncService.schedulePush()` (5s debounce) → batch REST calls to Firestore. Pull runs on startup + every 5 min.
3636
- **When adding a column to `tasks`:** Also add to `Task` model (`toMap`/`fromMap`/`copyWith`), `taskToFirestoreFields`, and `taskFromFirestoreDoc` in `firestore_service.dart`.
3737
- **Provider pattern:** 3 providers (`TaskProvider`, `AuthProvider`, `ThemeProvider`) + `SyncService` via `ProxyProvider<AuthProvider, SyncService>`. `TaskProvider._refreshCurrentList()` reloads children only, NOT `_currentParent` — see gotcha below. **Every provider method that mutates data must call `_refreshAfterMutation()`** (which reloads the task list, auxiliary data, and calls `notifyListeners` + `onMutation`). Calling only `onMutation` without `_refreshAfterMutation` causes stale UI — icons, indicators, and sort order won't update until the user navigates away and back.

docs/CODE_REVIEW.md

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.

docs/TEST_COVERAGE.md

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

docs/UI_VIEWS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ Reference for manual test instructions. Use these names consistently.
3333

3434
### All Tasks tab
3535
- **Task card** — grid card for each task. Shows task name, top-left indicator icons (Today's 5, worked-on, started, priority, someday, deadline, scheduled today, starred). **Tap** navigates into the task. **Long-press** opens a context-menu bottom sheet. Items: **Rename**, **Also show under...** (add another parent), **Do after...** (add/change dependency), **Schedule**, **Delete**, plus two that appear **only when drilled into a parent (non-root), never at root level**: **Move to...** (`drive_file_move_outline` — move from the current list to another) and **Remove from here** (unlink from the current parent). At root level a task has no "current list" to move from or unlink from, so both are hidden.
36+
- **Known limitation — "Also show under..." on a root task removes it from the root list.** The root level shows only **parentless** tasks (root = the *absence* of a parent, not a parent itself). So the first time you "Also show under..." a task that currently sits at root, it gains a parent and therefore **disappears from the root list**, appearing under that parent instead — despite the "*also* show under" wording implying it would stay at root as well. A second "Also show under..." then correctly adds an *additional* parent (the task is already non-root). To keep a copy visible at the top level you would need a genuine root pseudo-parent, which does not exist. This is pre-existing DAG behavior, unrelated to any specific fix — noted here to avoid manual-test confusion.
3637
- **Task list** — hierarchical list. Shows children of the current parent. Root level shows top-level tasks + Inbox.
37-
- **Inbox section** — collapsible section at top showing unorganized tasks.
38+
- **Inbox section** — collapsible section at top showing unorganized (inbox) tasks. Tapping the section header toggles expand/collapse; a **"File all"** action files every inbox task in one pass. **Interactions on an inbox task row:** a **tap (single press)** opens the **"File under…"** (triage) dialog to file it under a parent (or keep it at top level, which dismisses it from the inbox); a **long-press** drills **into** the task (navigateInto). This is the inverse of the main list rows, where a tap navigates in — inbox tasks prioritize filing, so tap = file, long-press = open.
3839
- **Leaf detail view** — appears when navigating into a leaf task (a task with no children). Shows task name, "Done today" button, "Done for good!" button, "Start"/"Started" toggle, priority selector, schedule/deadline info, dependencies, parent breadcrumbs. This is NOT the same as the Today's 5 bottom sheet.
3940
- **"Do after..." icon (add_task)** — shown when the task has **no** dependency. **Tap** opens the "Do X after..." picker dialog to add a dependency.
4041
- **Dependency icon (hourglass)** — replaces the "Do after..." icon when the task has a dependency. **Tap** navigates to the blocker task. **Long-press** opens the "Do X after..." picker dialog to change or remove the dependency. Color behavior: **primary color** when actively blocked, **greyed out** when the blocker is no longer blocking (e.g. marked "Done today"). When the blocker is completed ("Done for good") or skipped, the dependency row is deleted from the DB, so the hourglass **disappears entirely**.

lib/data/database_helper.dart

Lines changed: 140 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -568,13 +568,73 @@ class DatabaseHelper {
568568
return insertedIds;
569569
}
570570

571+
/// Inserts a task and links it to every id in [parentIds] — task row, all
572+
/// relationship edges, and their sync-queue entries — in ONE transaction, so
573+
/// a crash can't leave an orphan or partially-parented task. Returns the id.
574+
//
575+
// CR-fix R-10: addTask() previously ran insertTask() in its own transaction
576+
// and then a SEPARATE addRelationship() transaction per parent, so the task
577+
// row and its edges were not one atomic unit. A brand-new task can't form a
578+
// cycle, so (unlike addRelationship's callers) no hasPath check is needed.
579+
Future<int> insertTaskWithParents(Task task, List<int> parentIds) async {
580+
final db = await database;
581+
final now = DateTime.now().millisecondsSinceEpoch;
582+
return await db.transaction((txn) async {
583+
final map = task.toMap();
584+
map['sync_id'] ??= _uuid.v4();
585+
map['updated_at'] = now;
586+
map['sync_status'] = 'pending';
587+
final childSyncId = map['sync_id'] as String;
588+
final childId = await txn.insert('tasks', map);
589+
if (parentIds.isNotEmpty) {
590+
// Resolve parent sync_ids in one query for the sync-queue enqueue.
591+
final placeholders = parentIds.map((_) => '?').join(',');
592+
final rows = await txn.rawQuery(
593+
'SELECT id, sync_id FROM tasks WHERE id IN ($placeholders)',
594+
parentIds);
595+
final parentSyncIds = <int, String>{};
596+
for (final r in rows) {
597+
final sid = r['sync_id'] as String?;
598+
if (sid != null) parentSyncIds[r['id'] as int] = sid;
599+
}
600+
final seen = <int>{};
601+
for (final parentId in parentIds) {
602+
if (!seen.add(parentId)) continue; // de-dupe repeated parents
603+
await txn.insert('task_relationships', {
604+
'parent_id': parentId,
605+
'child_id': childId,
606+
}, conflictAlgorithm: ConflictAlgorithm.ignore);
607+
final parentSyncId = parentSyncIds[parentId];
608+
if (parentSyncId != null) {
609+
await txn.insert('sync_queue', {
610+
'entity_type': 'relationship',
611+
'action': 'add',
612+
'key1': parentSyncId,
613+
'key2': childSyncId,
614+
'created_at': now,
615+
});
616+
}
617+
}
618+
}
619+
return childId;
620+
});
621+
}
622+
571623
Future<void> addRelationship(int parentId, int childId) async {
572624
final db = await database;
573625
await db.transaction((txn) async {
574-
await txn.insert('task_relationships', {
626+
// CR-fix M-45: was the default 'abort' conflict algorithm — re-inserting
627+
// an existing (parent_id, child_id) edge threw a UNIQUE-constraint error.
628+
// Callers only run the hasPath cycle check (which is false for an
629+
// already-existing edge), so a future caller re-adding an edge would
630+
// crash (inconsistent with moveTask/fileTask, which pre-check). Ignore
631+
// makes it a no-op, and we skip the redundant sync 'add' enqueue below
632+
// when nothing was actually inserted.
633+
final inserted = await txn.insert('task_relationships', {
575634
'parent_id': parentId,
576635
'child_id': childId,
577-
});
636+
}, conflictAlgorithm: ConflictAlgorithm.ignore);
637+
if (inserted == 0) return;
578638
final rows = await txn.rawQuery(
579639
'SELECT id, sync_id FROM tasks WHERE id IN (?, ?)', [parentId, childId]);
580640
final syncIds = <int, String>{};
@@ -1184,31 +1244,44 @@ class DatabaseHelper {
11841244
}
11851245
}
11861246

1187-
// For tasks without own deadline, check ancestors
1247+
// For tasks without own deadline, inherit the nearest ancestor's deadline.
1248+
// CR-fix I-52: this was a per-task loop, each iteration running a full
1249+
// recursive ancestor CTE — an N+1 on the refresh hot path
1250+
// (_loadAuxiliaryData runs on every mutation). A root with 50 undeadlined
1251+
// children fired 50 recursive walks per tap. Now one batched recursive CTE
1252+
// per 500-id chunk, keyed by target_id (same shape as
1253+
// getEffectiveScheduledTodayIds). Rows come back nearest-ancestor-first per
1254+
// target; we keep the first hit for each, matching the old ORDER BY depth
1255+
// ASC LIMIT 1 semantics.
11881256
final remaining = taskIds.where((id) => !result.containsKey(id)).toList();
11891257
for (var i = 0; i < remaining.length; i += 500) {
11901258
final batch = remaining.sublist(i, i + 500 > remaining.length ? remaining.length : i + 500);
1191-
for (final taskId in batch) {
1192-
final rows = await db.rawQuery('''
1193-
WITH RECURSIVE ancestors(id, depth) AS (
1194-
SELECT parent_id, 1 FROM task_relationships WHERE child_id = ?
1195-
UNION ALL
1196-
SELECT tr.parent_id, a.depth + 1
1197-
FROM task_relationships tr
1198-
INNER JOIN ancestors a ON tr.child_id = a.id
1199-
)
1200-
SELECT t.deadline, t.deadline_type FROM tasks t
1201-
INNER JOIN ancestors a ON t.id = a.id
1202-
WHERE t.deadline IS NOT NULL
1203-
ORDER BY a.depth ASC
1204-
LIMIT 1
1205-
''', [taskId]);
1206-
if (rows.isNotEmpty) {
1207-
result[taskId] = (
1208-
deadline: rows.first['deadline'] as String,
1209-
type: rows.first['deadline_type'] as String? ?? 'due_by',
1210-
);
1211-
}
1259+
final placeholders = batch.map((_) => '?').join(',');
1260+
final rows = await db.rawQuery('''
1261+
WITH RECURSIVE target_ancestors(target_id, ancestor_id, depth) AS (
1262+
SELECT tr.child_id, tr.parent_id, 1
1263+
FROM task_relationships tr
1264+
WHERE tr.child_id IN ($placeholders)
1265+
UNION ALL
1266+
SELECT ta.target_id, tr.parent_id, ta.depth + 1
1267+
FROM target_ancestors ta
1268+
INNER JOIN task_relationships tr ON tr.child_id = ta.ancestor_id
1269+
)
1270+
SELECT ta.target_id AS target_id, t.deadline AS deadline,
1271+
t.deadline_type AS deadline_type
1272+
FROM target_ancestors ta
1273+
INNER JOIN tasks t ON t.id = ta.ancestor_id
1274+
WHERE t.deadline IS NOT NULL
1275+
ORDER BY ta.target_id ASC, ta.depth ASC
1276+
''', batch);
1277+
for (final row in rows) {
1278+
final targetId = row['target_id'] as int;
1279+
// Nearest-first ordering → the first row per target is the shallowest.
1280+
if (result.containsKey(targetId)) continue;
1281+
result[targetId] = (
1282+
deadline: row['deadline'] as String,
1283+
type: row['deadline_type'] as String? ?? 'due_by',
1284+
);
12121285
}
12131286
}
12141287
return result;
@@ -2487,6 +2560,49 @@ class DatabaseHelper {
24872560
}
24882561
}
24892562

2563+
/// Returns the sync_ids of every local task that has one.
2564+
// CR-fix I-49: used by the full-pull task reconciliation to find local tasks
2565+
// that no longer exist remotely (deleted on another device).
2566+
Future<List<String>> getAllTaskSyncIds() async {
2567+
final db = await database;
2568+
final rows = await db.query('tasks',
2569+
columns: ['sync_id'], where: 'sync_id IS NOT NULL');
2570+
return rows.map((r) => r['sync_id'] as String).toList();
2571+
}
2572+
2573+
/// Returns the sync_ids of tasks pending push (locally created/edited).
2574+
// CR-fix I-49: guards the delta/full-pull deletion path so a task that was
2575+
// just re-created locally (not yet pushed) isn't dropped by a stale tombstone
2576+
// or by absence from a remote set — mirrors getPendingSyncAddKeys for edges.
2577+
Future<Set<String>> getPendingTaskSyncIds() async {
2578+
final db = await database;
2579+
final rows = await db.query('tasks',
2580+
columns: ['sync_id'],
2581+
where: "sync_status = 'pending' AND sync_id IS NOT NULL");
2582+
return rows.map((r) => r['sync_id'] as String).toSet();
2583+
}
2584+
2585+
/// Removes a task in response to a remote deletion (tombstone / absence on
2586+
/// full pull). Does NOT enqueue a sync deletion — we are applying remote
2587+
/// state, mirroring [removeRelationshipFromRemote]. Returns true if a row was
2588+
/// removed. Edges/deps/schedules cascade via ON DELETE CASCADE; the today's-5
2589+
/// tables have no FK, so they are cleaned explicitly to avoid orphans.
2590+
Future<bool> deleteTaskBySyncId(String syncId) async {
2591+
final db = await database;
2592+
return await db.transaction((txn) async {
2593+
final rows = await txn.query('tasks',
2594+
columns: ['id'], where: 'sync_id = ?', whereArgs: [syncId]);
2595+
if (rows.isEmpty) return false;
2596+
final id = rows.first['id'] as int;
2597+
await txn.delete('todays_five_state',
2598+
where: 'task_id = ?', whereArgs: [id]);
2599+
await txn.delete('todays_five_deadline_suppressed',
2600+
where: 'task_id = ?', whereArgs: [id]);
2601+
await txn.delete('tasks', where: 'id = ?', whereArgs: [id]);
2602+
return true;
2603+
});
2604+
}
2605+
24902606
/// Returns all relationships as (parentSyncId, childSyncId) pairs.
24912607
Future<List<({String parentSyncId, String childSyncId})>> getAllRelationshipsWithSyncIds() async {
24922608
final db = await database;

lib/providers/task_provider.dart

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,24 +179,29 @@ class TaskProvider extends ChangeNotifier {
179179
isInbox: isInbox,
180180
isStarred: isStarred,
181181
starOrder: starOrder);
182-
final taskId = await _db.insertTask(task);
183182

183+
// CR-fix R-10: collect all parent ids and insert the task + every edge in a
184+
// single transaction (insertTaskWithParents) instead of insertTask() plus
185+
// one addRelationship() txn per parent — the old split wasn't atomic, so a
186+
// crash mid-way left an orphan or partially-parented task.
187+
//
184188
// [atRoot] forces a root-level insert regardless of current navigation
185189
// state. Used by callers in other tabs (Today's 5, Starred) so the new
186190
// task isn't silently nested under whatever parent the All Tasks tab last
187191
// drilled into. (isInbox also implies root, enforced by the invariant
188192
// above — so plain atRoot suffices here.)
193+
final parentIds = <int>[];
189194
if (!atRoot && _currentParent != null) {
190-
await _db.addRelationship(_currentParent!.id!, taskId);
195+
parentIds.add(_currentParent!.id!);
191196
}
192-
193197
if (additionalParentIds != null) {
194198
for (final parentId in additionalParentIds) {
195199
if (atRoot || parentId != _currentParent?.id) {
196-
await _db.addRelationship(parentId, taskId);
200+
parentIds.add(parentId);
197201
}
198202
}
199203
}
204+
final taskId = await _db.insertTaskWithParents(task, parentIds);
200205

201206
// Bug fix: without deferNotify, _refreshAfterMutation() fired immediately
202207
// after task insert, triggering notifyListeners -> refreshSnapshots() ->
@@ -304,7 +309,12 @@ class TaskProvider extends ChangeNotifier {
304309
orElse: () => throw StateError('Task $taskId not found in current list'));
305310
final removedDeps = await _db.completeTask(taskId);
306311
onMutation?.call();
307-
await navigateBack();
312+
// CR-fix M-46: navigateBack() refreshes only when _parentStack is non-empty
313+
// (returns false otherwise). On the empty-stack branch the DB mutation +
314+
// sync push already fired but nothing refreshed the list / notifyListeners,
315+
// leaving stale UI. Fall back to a refresh. (onMutation already fired above,
316+
// so refresh the list directly rather than re-firing it.)
317+
if (!await navigateBack()) await _refreshCurrentList();
308318
return (task: task, removedDeps: removedDeps);
309319
}
310320

@@ -317,7 +327,8 @@ class TaskProvider extends ChangeNotifier {
317327
orElse: () => throw StateError('Task $taskId not found in current list'));
318328
final removedDeps = await _db.skipTask(taskId);
319329
onMutation?.call();
320-
await navigateBack();
330+
// CR-fix M-46: refresh on the empty-stack branch too (see completeTask).
331+
if (!await navigateBack()) await _refreshCurrentList();
321332
return (task: task, removedDeps: removedDeps);
322333
}
323334

@@ -795,7 +806,8 @@ class TaskProvider extends ChangeNotifier {
795806
await _db.markWorkedOn(taskId);
796807
if (alsoStart) await _db.startTask(taskId);
797808
onMutation?.call();
798-
await navigateBack(); // single _refreshCurrentList()
809+
// CR-fix M-46: refresh on the empty-stack branch too (see completeTask).
810+
if (!await navigateBack()) await _refreshCurrentList();
799811
}
800812

801813
Future<void> unmarkWorkedOn(int taskId, {int? restoreTo}) async {

0 commit comments

Comments
 (0)