Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/TEST_COVERAGE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/UI_VIEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Reference for manual test instructions. Use these names consistently.
- **Starred, subtask add** (inside a card's expanded dialog) — `add_link` ("Add here") links the existing task as a subtask of the starred parent (multi-parent link). Typing the parent's own name shows a "That's this task" snackbar; a link that would form a loop shows "Couldn't add — it would create a loop". These snackbars (and the "Added … here" one, with **Undo**) render **inside the expanded dialog** — the dialog hosts its own `ScaffoldMessenger` so they aren't hidden behind it on the page.

This is the inverse of the **create-from-search** affordance (which offers to *create* when a search matches nothing). The two are complementary — search matching is substring-based, so a create-from-search entry never simultaneously shows an exact-match suggestion.
- **Brain dump dialog** — opened via the "Add multiple" toggle in the Add Task dialog. Multi-line text field, one task per line (blank lines ignored). Shows a live "N tasks" count. The action button is **disabled and labelled "Add"** when no non-blank lines exist; once there's input it enables and shows the count ("Add N"). "Inbox" toggle shown only at root level. On submit, creates all tasks at once with an "Added N tasks" snackbar.
- **Brain dump dialog** — opened via the "Add multiple" toggle in the Add Task dialog. Multi-line text field, one task per line (blank lines ignored). Shows a live "N tasks" count. The action button is **disabled and labelled "Add"** when no non-blank lines exist; once there's input it enables and shows the count ("Add N"). "Inbox" toggle shown only at root level. **The Inbox toggle opens with whatever state the user had set in the Add Task dialog before tapping "Add multiple"** — turn Inbox off there and the brain dump opens with it off, so the batch honours the choice. (Bug fix: the state used to be dropped at the switch, and the brain dump reopened with its own default-ON, silently filing the batch into the Inbox.) A brain dump not reached via that switch still defaults ON. There is **no "Pin" toggle** here — bulk add never pins, so any pin choice made in the Add Task dialog is deliberately discarded when switching. On submit, creates all tasks at once with an "Added N tasks" snackbar.
- **Delete task dialog** — appears when deleting a non-leaf task. Options: "Keep sub-tasks" (reparents children to deleted task's parent) or "Delete everything" (deletes entire subtree). Leaf tasks delete immediately with undo snackbar, no dialog.
- **"This task is pinned" warning dialog** — appears when tapping the + FAB on a task that is pinned in Today's 5. Title: "This task is pinned", body: explains that adding a subtask makes it a parent, so it will drop out of Today's 5 (the pin is not transferred to a child). Buttons: "Cancel" / "Add anyway". Shown before the Add Task dialog opens.
- **Schedule dialog** — opened via the calendar icon on a task card or leaf detail. Has deadline picker (date + "Due by"/"On" toggle), recurrence settings.
Expand Down
53 changes: 24 additions & 29 deletions lib/widgets/add_task_dialog.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../utils/display_utils.dart' show normalizeUrl, isAllowedUrl, showInfoSnackBar, UrlTextField;
import 'inbox_toggle_chip.dart';

/// Result from AddTaskDialog: a single task name, a request to switch to brain
/// dump mode, or a request to use an already-existing task instead of creating
Expand All @@ -17,7 +18,17 @@ class SingleTask extends AddTaskResult {

class SwitchToBrainDump extends AddTaskResult {
final String initialText;
SwitchToBrainDump({this.initialText = ''});

/// The Inbox toggle's state when the user tapped "Add multiple", carried over
/// so the brain dump opens with the choice they already made.
///
/// Bug fix: this used to be dropped. Before — turn Inbox OFF, tap "Add
/// multiple", and the brain dump opened with Inbox back ON (its own default),
/// silently filing the batch into the Inbox against the user's choice. After —
/// the toggle state carries across the switch.
final bool addToInbox;

SwitchToBrainDump({this.initialText = '', this.addToInbox = true});
}

/// The user tapped an inline "already exists" suggestion — they want to act on
Expand Down Expand Up @@ -238,33 +249,9 @@ class _AddTaskDialogState extends State<AddTaskDialog> {
List<Widget> _buildToggles(ColorScheme colorScheme) {
return [
if (widget.showInboxOption)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setState(() => _inbox = !_inbox),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_inbox ? Icons.inbox : Icons.inbox_outlined,
size: 16,
color: _inbox
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Inbox',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _inbox
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
],
),
),
InboxToggleChip(
value: _inbox,
onChanged: (v) => setState(() => _inbox = v),
),
if (widget.showPinOption)
InkWell(
Expand Down Expand Up @@ -364,7 +351,15 @@ class _AddTaskDialogState extends State<AddTaskDialog> {
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context, SwitchToBrainDump(initialText: _controller.text.trim())),
// Carry _inbox across so the brain dump keeps the user's Inbox
// choice instead of resetting to its own default-ON.
onPressed: () => Navigator.pop(
context,
SwitchToBrainDump(
initialText: _controller.text.trim(),
addToInbox: _inbox,
),
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
textStyle: Theme.of(context).textTheme.bodySmall,
Expand Down
10 changes: 8 additions & 2 deletions lib/widgets/add_task_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ class AddTaskFlow {
if (result is SingleTask) {
await _addOne(context, result);
} else if (result is SwitchToBrainDump) {
await _addMany(context, result.initialText);
await _addMany(context, result.initialText,
initialInbox: result.addToInbox);
} else if (result is UseExisting) {
await onUseExisting?.call(result.task);
}
Expand Down Expand Up @@ -208,12 +209,17 @@ class AddTaskFlow {
await onCompleted?.call(1);
}

Future<void> _addMany(BuildContext context, String initialText) async {
/// [initialInbox] is the Inbox toggle state the user had set in the Add Task
/// dialog before tapping "Add multiple" — forwarded so the brain dump opens
/// with that choice rather than its own default-ON.
Future<void> _addMany(BuildContext context, String initialText,
{bool initialInbox = true}) async {
final result = await showDialog<BrainDumpResult>(
context: context,
builder: (_) => BrainDumpDialog(
initialText: initialText,
showInboxOption: showInboxOption,
initialInbox: initialInbox,
),
);
if (!context.mounted || result == null || result.names.isEmpty) return;
Expand Down
47 changes: 18 additions & 29 deletions lib/widgets/brain_dump_dialog.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'inbox_toggle_chip.dart';

/// Dialog for rapid multi-task entry. Each line becomes a separate task.
/// Returns a list of task names (non-empty, trimmed).
Expand All @@ -13,7 +14,18 @@ class BrainDumpDialog extends StatefulWidget {
final String initialText;
final bool showInboxOption;

const BrainDumpDialog({super.key, this.initialText = '', this.showInboxOption = false});
/// Starting state of the Inbox toggle. Defaults ON (a fresh brain dump files
/// to the Inbox), but callers arriving from the Add Task dialog's "Add
/// multiple" pass the toggle state the user had already set there, so the
/// choice survives the switch instead of silently reverting to ON.
final bool initialInbox;

const BrainDumpDialog({
super.key,
this.initialText = '',
this.showInboxOption = false,
this.initialInbox = true,
});

@override
State<BrainDumpDialog> createState() => _BrainDumpDialogState();
Expand All @@ -22,11 +34,12 @@ class BrainDumpDialog extends StatefulWidget {
class _BrainDumpDialogState extends State<BrainDumpDialog> {
final _controller = TextEditingController();
int _lineCount = 0;
bool _inbox = true;
late bool _inbox;

@override
void initState() {
super.initState();
_inbox = widget.initialInbox;
if (widget.initialText.isNotEmpty) {
_controller.text = widget.initialText;
}
Expand Down Expand Up @@ -107,33 +120,9 @@ class _BrainDumpDialogState extends State<BrainDumpDialog> {
),
const Spacer(),
if (widget.showInboxOption)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setState(() => _inbox = !_inbox),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_inbox ? Icons.inbox : Icons.inbox_outlined,
size: 16,
color: _inbox
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(120),
),
const SizedBox(width: 4),
Text(
'Inbox',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _inbox
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(120),
),
),
],
),
),
InboxToggleChip(
value: _inbox,
onChanged: (v) => setState(() => _inbox = v),
),
],
),
Expand Down
60 changes: 60 additions & 0 deletions lib/widgets/inbox_toggle_chip.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';

/// The "Inbox" toggle chip shown by both the Add Task dialog and the Brain dump
/// dialog.
///
/// Extracted because the two had drifted: the ON state matched, but the OFF
/// state used `onSurfaceVariant` in the Add Task dialog and
/// `onSurfaceVariant.withAlpha(120)` in the brain dump — so the same chip
/// visibly dimmed when the user tapped "Add multiple". That was easy to miss
/// while the brain dump's OFF state was only reachable by tapping, but once the
/// Inbox choice began carrying across the switch, OFF became the brain dump's
/// *opening* state and the mismatch showed on every use. The extra fade also
/// read as "disabled" rather than "off".
///
/// One widget, so the two placements cannot diverge again.
class InboxToggleChip extends StatelessWidget {
const InboxToggleChip({
super.key,
required this.value,
required this.onChanged,
});

/// Whether the task(s) will be filed in the Inbox.
final bool value;

final ValueChanged<bool> onChanged;

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// ON = accent; OFF = plain muted (NOT further faded — that reads as
// disabled, and the chip is always tappable).
final color = value ? colorScheme.primary : colorScheme.onSurfaceVariant;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
value ? Icons.inbox : Icons.inbox_outlined,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
'Inbox',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: color),
),
],
),
),
);
}
}
42 changes: 42 additions & 0 deletions test/screens/starred_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,48 @@ void main() {
expect(parents, isEmpty,
reason: 'screen FAB must add at root, not under any task');
});

// [Regression] Same dropped-Inbox-toggle bug as the All Tasks "+" FAB, but
// through the Starred screen's own AddTaskFlow (a different addBatch closure
// — isStarred + atRoot). Before: turning Inbox OFF and tapping "Add
// multiple" reopened the brain dump with Inbox back ON, so the whole batch
// was filed into the Inbox against the user's choice. After: the batch lands
// as plain starred root tasks.
testWidgets('screen FAB: Inbox OFF survives "Add multiple"',
(tester) async {
await pumpAndLoad(tester, buildTestWidget());

await tester.tap(find.byType(FloatingActionButton));
await pumpAsync(tester);
expect(find.text('Inbox'), findsOneWidget);

// Turn Inbox OFF, then switch to the brain dump.
await tester.tap(find.text('Inbox'));
await pumpAsync(tester);
await tester.tap(find.text('Add multiple'));
await pumpAsync(tester);
expect(find.text('Brain dump'), findsOneWidget);

await tester.enterText(
find.byType(TextField).first, 'Starred one\nStarred two');
await pumpAsync(tester);
await tester.runAsync(() async {
await tester.tap(find.text('Add 2'));
});
await pumpAsync(tester);

final batch = await tester.runAsync(() async {
final all = await db.getAllTasks();
return all.where((t) => t.name.startsWith('Starred ')).toList();
});
expect(batch, hasLength(2), reason: 'both lines created');
for (final t in batch!) {
expect(t.isInbox, isFalse,
reason: '"${t.name}" must honour the Inbox-OFF choice');
expect(t.isStarred, isTrue,
reason: 'screen FAB auto-stars the batch too');
}
});
});

group('StarredScreen - search', () {
Expand Down
40 changes: 40 additions & 0 deletions test/screens/task_list_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,46 @@ void main() {
await pumpAsync(tester);
}

// [Regression] End-to-end for the dropped-Inbox-toggle bug, through the real
// AddTaskDialog → SwitchToBrainDump → AddTaskFlow → BrainDumpDialog chain
// (the unit tests in small_widgets_test.dart cover each seam in isolation).
// Before: turning Inbox OFF at root and then tapping "Add multiple" reopened
// the brain dump with Inbox back ON, so the whole batch was filed into the
// Inbox against the user's choice. After: every task lands outside the Inbox.
testWidgets('Inbox OFF survives the switch to "Add multiple"',
(tester) async {
await tester.runAsync(() => provider.loadRootTasks());
await pumpAndLoad(tester, buildTestWidget());

// Root level, so the "+" FAB's dialog offers the Inbox toggle.
await tester.tap(find.byType(FloatingActionButton));
await pumpAsync(tester);
expect(find.text('Inbox'), findsOneWidget);

// Turn Inbox OFF, then switch to the brain dump.
await tester.tap(find.text('Inbox'));
await pumpAsync(tester);
await tester.tap(find.text('Add multiple'));
await pumpAsync(tester);

await tester.enterText(
find.byType(TextField).first, 'Batch one\nBatch two');
await pumpAsync(tester);
await tester.runAsync(() async {
await tester.tap(find.textContaining('Add'));
});
await pumpAsync(tester);

final all = await tester.runAsync(() => db.getAllTasks()) ?? [];
final batch =
all.where((t) => t.name.startsWith('Batch ')).toList();
expect(batch.length, 2, reason: 'both lines created');
for (final t in batch) {
expect(t.isInbox, isFalse,
reason: '"${t.name}" must honour the Inbox-OFF choice');
}
});

// [Regression] The app bar search action still opens the "Search tasks"
// picker after the body was extracted into the shared helper.
testWidgets('app bar search icon opens the "Search tasks" dialog',
Expand Down
Loading