Merge branch 'todo-fb' into integration

This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:13:33 +01:00
commit 82436ad030
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 1618 additions and 561 deletions

View file

@ -0,0 +1,19 @@
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A setting's [title] with the question mark that explains it ([help],
/// halkyon_ui's [HelpButton]) beside it, rather than the explanation always
/// below (the developer's review, 3 Nov 2026).
class const TitleWithHelp(
final String title,
final String help, {
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Flexible(child: Text(title)),
HelpButton(help, title: title),
],
);
}

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart'; import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart'; import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/widget/title_with_help.dart';
import 'package:halkyon_meditate_flutter/src/feature/practice/widget/practice_texts.dart'; import 'package:halkyon_meditate_flutter/src/feature/practice/widget/practice_texts.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart'; import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart'; import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart';
@ -67,8 +68,7 @@ class const SittingSettingsCard({super.key}) extends StatelessWidget {
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.sunMedium), secondary: const Icon(LucideIcons.sunMedium),
title: Text(l10n.keepScreenOnTitle), title: TitleWithHelp(l10n.keepScreenOnTitle, l10n.keepScreenOnHint),
subtitle: Text(l10n.keepScreenOnHint),
value: settings.keepScreenOn, value: settings.keepScreenOn,
onChanged: (on) => onChanged: (on) =>
_write(context, settings.copyWith(keepScreenOn: on)), _write(context, settings.copyWith(keepScreenOn: on)),
@ -125,8 +125,7 @@ class const PracticeSettingsCard({super.key}) extends StatelessWidget {
children: [ children: [
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.flame), secondary: const Icon(LucideIcons.flame),
title: Text(l10n.showStreakTitle), title: TitleWithHelp(l10n.showStreakTitle, l10n.showStreakHint),
subtitle: Text(l10n.showStreakHint),
value: settings.showStreak, value: settings.showStreak,
onChanged: (on) => _write(context, settings.copyWith(showStreak: on)), onChanged: (on) => _write(context, settings.copyWith(showStreak: on)),
), ),
@ -149,8 +148,7 @@ class const JournalSettingsCard({super.key}) extends StatelessWidget {
children: [ children: [
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.sunrise), secondary: const Icon(LucideIcons.sunrise),
title: Text(l10n.dreamPromptTitle), title: TitleWithHelp(l10n.dreamPromptTitle, l10n.dreamPromptHint),
subtitle: Text(l10n.dreamPromptHint),
value: settings.dreamPrompt, value: settings.dreamPrompt,
onChanged: (on) => onChanged: (on) =>
_write(context, settings.copyWith(dreamPrompt: on)), _write(context, settings.copyWith(dreamPrompt: on)),

View file

@ -660,6 +660,23 @@ void main() {
}); });
group('Settings of sitting (ADR 0038, D11)', () { group('Settings of sitting (ADR 0038, D11)', () {
testWidgets('what a setting does stays behind its question mark until '
"asked (the developer's review, 3 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
await openSettings(tester);
expect(find.text('While a sitting runs'), findsNothing);
final help = find.descendant(
of: find.widgetWithText(SwitchListTile, 'Keep the screen on'),
matching: find.byType(HelpButton),
);
await tester.ensureVisible(help);
await tester.pumpAndSettle();
await tester.tap(help);
await tester.pumpAndSettle();
expect(find.text('While a sitting runs'), findsOneWidget);
});
testWidgets('the bell picked, the bells between and the screen are kept ' testWidgets('the bell picked, the bells between and the screen are kept '
"in the person's sealed settings", (tester) async { "in the person's sealed settings", (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);

File diff suppressed because one or more lines are too long

View file

@ -60,7 +60,7 @@
"projectGone": "Dieses Projekt wurde gelöscht.", "projectGone": "Dieses Projekt wurde gelöscht.",
"projectMenu": "Mehr", "projectMenu": "Mehr",
"projectPickTitle": "Projekt", "projectPickTitle": "Projekt",
"sectionAdd": "Neuer Abschnitt", "sectionAdd": "Abschnitt hinzufügen",
"sectionEdit": "Abschnitt umbenennen", "sectionEdit": "Abschnitt umbenennen",
"sectionDelete": "Abschnitt löschen", "sectionDelete": "Abschnitt löschen",
"sectionDeleteTitle": "Abschnitt löschen?", "sectionDeleteTitle": "Abschnitt löschen?",
@ -243,5 +243,8 @@
"snoozeTitle": "Schlummern für", "snoozeTitle": "Schlummern für",
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}", "snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
"reminderSnooze": "{count} Min. schlummern", "reminderSnooze": "{count} Min. schlummern",
"monthMore": "{count} weitere" "monthMore": "{count} weitere",
"dayHeadingPattern": "EEEE, d. MMM",
"overdueHide": "Überfälliges ausblenden",
"overdueShow": "Überfälliges einblenden"
} }

View file

@ -114,7 +114,7 @@
"projectGone": "This project was deleted.", "projectGone": "This project was deleted.",
"projectMenu": "More", "projectMenu": "More",
"projectPickTitle": "Project", "projectPickTitle": "Project",
"sectionAdd": "New section", "sectionAdd": "Add section",
"sectionEdit": "Rename section", "sectionEdit": "Rename section",
"sectionDelete": "Delete section", "sectionDelete": "Delete section",
"sectionDeleteTitle": "Delete the section?", "sectionDeleteTitle": "Delete the section?",
@ -778,5 +778,11 @@
"type": "int" "type": "int"
} }
} }
} },
"dayHeadingPattern": "EEEE, d MMM",
"@dayHeadingPattern": {
"description": "intl's DateFormat pattern of a day's heading in Today and Upcoming, as Todoist's (\"Tuesday, 3 Nov\"): EEEE the weekday, d the day, MMM the short month."
},
"overdueHide": "Hide what is overdue",
"overdueShow": "Show what is overdue"
} }

View file

@ -463,7 +463,7 @@ abstract class AppLocalizations {
/// No description provided for @sectionAdd. /// No description provided for @sectionAdd.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'New section'** /// **'Add section'**
String get sectionAdd; String get sectionAdd;
/// No description provided for @sectionEdit. /// No description provided for @sectionEdit.
@ -1563,6 +1563,24 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count} more'** /// **'{count} more'**
String monthMore(int count); String monthMore(int count);
/// intl's DateFormat pattern of a day's heading in Today and Upcoming, as Todoist's ("Tuesday, 3 Nov"): EEEE the weekday, d the day, MMM the short month.
///
/// In en, this message translates to:
/// **'EEEE, d MMM'**
String get dayHeadingPattern;
/// No description provided for @overdueHide.
///
/// In en, this message translates to:
/// **'Hide what is overdue'**
String get overdueHide;
/// No description provided for @overdueShow.
///
/// In en, this message translates to:
/// **'Show what is overdue'**
String get overdueShow;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -212,7 +212,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get projectPickTitle => 'Projekt'; String get projectPickTitle => 'Projekt';
@override @override
String get sectionAdd => 'Neuer Abschnitt'; String get sectionAdd => 'Abschnitt hinzufügen';
@override @override
String get sectionEdit => 'Abschnitt umbenennen'; String get sectionEdit => 'Abschnitt umbenennen';
@ -966,4 +966,13 @@ class AppLocalizationsDe extends AppLocalizations {
String monthMore(int count) { String monthMore(int count) {
return '$count weitere'; return '$count weitere';
} }
@override
String get dayHeadingPattern => 'EEEE, d. MMM';
@override
String get overdueHide => 'Überfälliges ausblenden';
@override
String get overdueShow => 'Überfälliges einblenden';
} }

View file

@ -212,7 +212,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get projectPickTitle => 'Project'; String get projectPickTitle => 'Project';
@override @override
String get sectionAdd => 'New section'; String get sectionAdd => 'Add section';
@override @override
String get sectionEdit => 'Rename section'; String get sectionEdit => 'Rename section';
@ -965,6 +965,15 @@ class AppLocalizationsEn extends AppLocalizations {
String monthMore(int count) { String monthMore(int count) {
return '$count more'; return '$count more';
} }
@override
String get dayHeadingPattern => 'EEEE, d MMM';
@override
String get overdueHide => 'Hide what is overdue';
@override
String get overdueShow => 'Show what is overdue';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -0,0 +1,19 @@
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A setting's [title] with the question mark that explains it ([help],
/// halkyon_ui's [HelpButton]) beside it, rather than the explanation always
/// below (the developer's review, 3 Nov 2026).
class const TitleWithHelp(
final String title,
final String help, {
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Flexible(child: Text(title)),
HelpButton(help, title: title),
],
);
}

View file

@ -179,7 +179,7 @@ class const DisplayButton({required final DisplayTarget target, super.key})
icon: Badge( icon: Badge(
smallSize: 8, smallSize: 8,
isLabelVisible: changed, isLabelVisible: changed,
child: const Icon(LucideIcons.slidersHorizontal), child: const Icon(LucideIcons.squareMenu),
), ),
onPressed: () => showDisplaySheet(context, target), onPressed: () => showDisplaySheet(context, target),
); );
@ -236,9 +236,7 @@ class const _DisplaySheet({required final DisplayTarget target})
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text( child: Text(
text, text,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall,
color: theme.colorScheme.primary,
),
), ),
); );
Widget choice( Widget choice(
@ -259,123 +257,135 @@ class const _DisplaySheet({required final DisplayTarget target})
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
heading(l10n.layoutTitle), heading(l10n.layoutTitle),
...target.layout(context, state, apply), _Group(children: target.layout(context, state, apply)),
SwitchListTile( _Group(
secondary: const Icon(LucideIcons.circleCheck), children: [
title: Text(l10n.displayCompleted), SwitchListTile(
value: view.completed, secondary: const Icon(LucideIcons.circleCheck),
onChanged: (on) => set(view.copyWith(completed: on)), title: Text(l10n.displayCompleted),
value: view.completed,
onChanged: (on) => set(view.copyWith(completed: on)),
),
if (target.occurrences(state))
SwitchListTile(
secondary: const Icon(LucideIcons.repeat),
title: Text(l10n.displayOccurrences),
value: view.occurrences,
onChanged: (on) => set(view.copyWith(occurrences: on)),
),
],
), ),
if (target.occurrences(state))
SwitchListTile(
secondary: const Icon(LucideIcons.repeat),
title: Text(l10n.displayOccurrences),
value: view.occurrences,
onChanged: (on) => set(view.copyWith(occurrences: on)),
),
heading(l10n.displaySort), heading(l10n.displaySort),
if (target.grouping(state)) _Group(
choice( children: [
LucideIcons.group, if (target.grouping(state))
l10n.displayGrouping, choice(
groupingText(l10n, view.grouping), LucideIcons.group,
() async { l10n.displayGrouping,
if (await _pickOne( groupingText(l10n, view.grouping),
context, () async {
l10n.displayGrouping, if (await _pickOne(
TaskGrouping.values, context,
view.grouping, l10n.displayGrouping,
(grouping) => groupingText(l10n, grouping), TaskGrouping.values,
) view.grouping,
case final grouping?) { (grouping) => groupingText(l10n, grouping),
await set(view.copyWith(grouping: grouping)); )
} case final grouping?) {
}, await set(view.copyWith(grouping: grouping));
), }
choice( },
LucideIcons.arrowUpDown, ),
l10n.displaySorting, choice(
sortingName(view.sorting), LucideIcons.arrowUpDown,
() async { l10n.displaySorting,
if (await _pickOne( sortingName(view.sorting),
context, () async {
l10n.displaySorting, if (await _pickOne(
target.sortings, context,
view.sorting, l10n.displaySorting,
sortingName, target.sortings,
) view.sorting,
case final sorting?) { sortingName,
await set(view.copyWith(sorting: sorting)); )
} case final sorting?) {
}, await set(view.copyWith(sorting: sorting));
}
},
),
],
), ),
heading(l10n.displayFilter), heading(l10n.displayFilter),
if (target.dateFilter) _Group(
choice( children: [
LucideIcons.calendar, if (target.dateFilter)
l10n.dateTitle, choice(
rangeText(l10n, view.date), LucideIcons.calendar,
() async { l10n.dateTitle,
if (await _pickOne( rangeText(l10n, view.date),
context, () async {
l10n.dateTitle, if (await _pickOne(
DayRange.values, context,
view.date, l10n.dateTitle,
(range) => rangeText(l10n, range), DayRange.values,
) view.date,
case final range?) { (range) => rangeText(l10n, range),
await set(view.copyWith(date: range)); )
} case final range?) {
}, await set(view.copyWith(date: range));
), }
choice( },
LucideIcons.target, ),
l10n.displayDeadline, choice(
rangeText(l10n, view.deadline, deadline: true), LucideIcons.target,
() async { l10n.displayDeadline,
if (await _pickOne( rangeText(l10n, view.deadline, deadline: true),
context, () async {
l10n.displayDeadline, if (await _pickOne(
DayRange.values, context,
view.deadline, l10n.displayDeadline,
(range) => rangeText(l10n, range, deadline: true), DayRange.values,
) view.deadline,
case final range?) { (range) => rangeText(l10n, range, deadline: true),
await set(view.copyWith(deadline: range)); )
} case final range?) {
}, await set(view.copyWith(deadline: range));
), }
choice( },
LucideIcons.flag, ),
l10n.priorityTitle, choice(
view.priorities.isEmpty LucideIcons.flag,
? l10n.displayAll l10n.priorityTitle,
: [ view.priorities.isEmpty
for (final priority in Priority.values) ? l10n.displayAll
if (view.priorities.contains(priority)) : [
priorityText(l10n, priority), for (final priority in Priority.values)
].join(', '), if (view.priorities.contains(priority))
() async { priorityText(l10n, priority),
if (await _pickPriorities(context, view.priorities) ].join(', '),
case final picked?) { () async {
await set(view.copyWith(priorities: picked)); if (await _pickPriorities(context, view.priorities)
} case final picked?) {
}, await set(view.copyWith(priorities: picked));
), }
choice( },
LucideIcons.tag, ),
l10n.displayLabel, choice(
view.labelIds.isEmpty LucideIcons.tag,
? l10n.displayAny l10n.displayLabel,
: [ view.labelIds.isEmpty
for (final id in view.labelIds) ?labels[id], ? l10n.displayAny
].join(', '), : [
() async { for (final id in view.labelIds) ?labels[id],
if (await pickLabels(context, [...view.labelIds]) ].join(', '),
case final picked?) { () async {
await set(view.copyWith(labelIds: {...picked})); if (await pickLabels(context, [...view.labelIds])
} case final picked?) {
}, await set(view.copyWith(labelIds: {...picked}));
}
},
),
],
), ),
], ],
), ),
@ -398,23 +408,94 @@ class const _DisplaySheet({required final DisplayTarget target})
} }
} }
/// A Display's layouts side by side, [selected] marked. /// A Display's layouts side by side, as Todoist's (the developer's review,
/// 3 Nov 2026): each a phone's outline with its layout's picture in it,
/// its name below on one line, and a radio button marking the one in use;
/// the picture, the name or the button picks it.
class const _LayoutPicker<T>({ class const _LayoutPicker<T>({
required final T selected, required final T selected,
required final List<(T, IconData, String)> segments, required final List<(T, IconData, String)> segments,
required final ValueChanged<T> onPicked, required final ValueChanged<T> onPicked,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return RadioGroup<T>(
groupValue: selected,
onChanged: (picked) {
if (picked != null) {
onPicked(picked);
}
},
child: Row(
children: [
for (final (value, icon, label) in segments)
Expanded(
child: Builder(
builder: (context) {
final on = value == selected;
final color = on ? scheme.primary : scheme.onSurfaceVariant;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onPicked(value),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
// A phone's outline, its layout's picture in it.
Container(
width: 44,
height: 72,
decoration: BoxDecoration(
color: on
? scheme.primary.withValues(alpha: 0.12)
: null,
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: color,
),
),
Radio<T>(value: value),
],
),
),
);
},
),
),
],
),
);
}
}
/// [children] in a box rounded at its ends, as Todoist's Display groups
/// its rows (the developer's review, 3 Nov 2026).
class const _Group({required final List<Widget> children})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: SegmentedButton<T>( child: Material(
showSelectedIcon: false, color: Theme.of(context).colorScheme.surfaceContainer,
segments: [ clipBehavior: Clip.antiAlias,
for (final (value, icon, label) in segments) shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
ButtonSegment(value: value, icon: Icon(icon), label: Text(label)), child: Column(
], mainAxisSize: MainAxisSize.min,
selected: {selected}, crossAxisAlignment: CrossAxisAlignment.stretch,
onSelectionChanged: (picked) => onPicked(picked.single), children: children,
),
), ),
); );
} }

View file

@ -9,6 +9,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart'; import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart'; import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -57,7 +58,7 @@ class const ProjectScreenBody({
const KeysResetBanner(), const KeysResetBanner(),
Expanded( Expanded(
child: switch (projectId) { child: switch (projectId) {
final id? => ProjectTasks(projectId: id), final id? => TaskRefresh(child: ProjectTasks(projectId: id)),
// No Inbox yet: made by the first device holding the // No Inbox yet: made by the first device holding the
// account's keys, or still on its way here. // account's keys, or still on its way here.
null null
@ -98,7 +99,9 @@ class const ProjectScreen({required final String projectId, super.key})
); );
} }
final content = project.content!; final content = project.content!;
return ProjectScreenBody( // Washed in its own colour, as a studyset's screens in Learn (the
// developer's review, 3 Nov 2026); without one, in Todo's.
final body = ProjectScreenBody(
title: project.inbox ? l10n.shellTabInbox : content.name, title: project.inbox ? l10n.shellTabInbox : content.name,
projectId: projectId, projectId: projectId,
menu: project.inbox menu: project.inbox
@ -136,5 +139,9 @@ class const ProjectScreen({required final String projectId, super.key})
), ),
], ],
); );
return switch (content.color) {
final color? => ScreenColor(color: color.background, child: body),
null => body,
};
} }
} }

View file

@ -16,6 +16,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart'; import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
@ -73,7 +74,7 @@ class const ProjectTasks({required final String projectId, super.key})
if (loose.isEmpty && if (loose.isEmpty &&
sections.isEmpty && sections.isEmpty &&
!state.tasks.any((t) => t.content.projectId == projectId)) { !state.tasks.any((t) => t.content.projectId == projectId)) {
return Center( return PullRoom(
child: EmptyListMessage( child: EmptyListMessage(
icon: LucideIcons.listTodo, icon: LucideIcons.listTodo,
title: l10n.projectEmptyTitle, title: l10n.projectEmptyTitle,
@ -117,6 +118,7 @@ class const ProjectTasks({required final String projectId, super.key})
), ),
), ),
child: CustomScrollView( child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
SliverList.list( SliverList.list(
children: [ children: [
@ -220,7 +222,7 @@ class const ProjectTasks({required final String projectId, super.key})
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (tasks.isEmpty) { if (tasks.isEmpty) {
return Center( return PullRoom(
child: EmptyListMessage( child: EmptyListMessage(
icon: LucideIcons.listFilter, icon: LucideIcons.listFilter,
title: l10n.viewNothingTitle, title: l10n.viewNothingTitle,
@ -241,6 +243,7 @@ class const ProjectTasks({required final String projectId, super.key})
labelOrder: [for (final label in state.labels) label.id], labelOrder: [for (final label in state.labels) label.id],
); );
return CustomScrollView( return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
for (final (index, group) in groups.indexed) for (final (index, group) in groups.indexed)
SliverMainAxisGroup( SliverMainAxisGroup(
@ -260,8 +263,10 @@ class const ProjectTasks({required final String projectId, super.key})
), ),
SliverList.builder( SliverList.builder(
itemCount: group.tasks.length, itemCount: group.tasks.length,
itemBuilder: (context, index) => itemBuilder: (context, index) => TaskTile(
TaskTile(task: group.tasks[index]), task: group.tasks[index],
position: rowPosition(index, group.tasks.length),
),
), ),
], ],
), ),
@ -288,9 +293,12 @@ class const ProjectTasks({required final String projectId, super.key})
sectionId: group.sectionId, sectionId: group.sectionId,
tasks: group.tasks, tasks: group.tasks,
index: index, index: index,
child: TaskTile(task: group.tasks[index]), child: TaskTile(
task: group.tasks[index],
position: rowPosition(index, group.tasks.length),
),
) )
: TaskTile(task: task); : TaskTile(task: task, position: rowPosition(index, group.tasks.length));
Widget _section( Widget _section(
BuildContext context, BuildContext context,

View file

@ -19,23 +19,24 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Quick add (ADR 0033, D8): a task's title, and chips for its date, /// Quick add (ADR 0033, D8): a task's title, and buttons for its date,
/// priority, project and labels, and a description where asked for. It /// priority, labels and project, and a description where asked for. It
/// goes into [projectId] (the Inbox where none is given), [sectionId] and /// goes into [projectId] (the Inbox where none is given), [sectionId] and
/// under [parentId] where given, dated [day] where given (at [time], minutes /// under [parentId] where given, dated [day] where given (at [time], minutes
/// after midnight, for [duration] where given: a slot of the 3-day grid, /// after midnight, for [duration] where given: a slot of the 3-day grid,
/// D17), last among its /// D17), last among its siblings, or at [spot] where the add button was
/// siblings, or at [spot] where the add button was dropped (D16), each /// dropped (D16), each next one below the one before. It stays for the next
/// next one below the one before. The sheet stays for the next task; going /// task; going back, or a tap beside it, closes it.
/// back closes it.
/// ///
/// It shows the title alone at first, its chips once typing starts, and its /// On a phone a small bar docked just above the keyboard, as Todoist's,
/// description from its + or the down arrow (the developer's, 1 Nov, D15). /// the list still in view above it ([QuickAddBar]; the developer's review,
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`, /// 3 Nov 2026); where sheets are dialogs (the desktop, expanded windows),
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`, /// the same bar in a dialog. Its description opens from its + or the down
/// dates, times, durations and recurrences in the app's language and /// arrow (D15). What the title says is read as it is typed
/// English, each highlighted and /// (`parseQuickAdd`): `#project`, `@label`, `{deadline}`, `!!1`, `p1`,
/// shown on its chip; a tap on one keeps it as text, and a chip picked /// `!30m` (as many as typed), `!1pm`, dates, times, durations and
/// recurrences in the app's language and English, each highlighted and
/// shown on its button; a tap on one keeps it as text, and a button's pick
/// takes its token out of the title. As `#` or `@` is typed, the projects /// takes its token out of the title. As `#` or `@` is typed, the projects
/// or labels it may mean are offered below the title, one tapped /// or labels it may mean are offered below the title, one tapped
/// completing it. /// completing it.
@ -48,9 +49,8 @@ Future<void> showQuickAdd(
int? time, int? time,
Duration? duration, Duration? duration,
DropSpot? spot, DropSpot? spot,
}) => showAdaptiveSheet<void>( }) {
context, final bar = _QuickAdd(
builder: (context) => _QuickAdd(
projectId: projectId ?? context.read<TodoCubit>().state.inboxId, projectId: projectId ?? context.read<TodoCubit>().state.inboxId,
sectionId: sectionId, sectionId: sectionId,
parentId: parentId, parentId: parentId,
@ -59,8 +59,47 @@ Future<void> showQuickAdd(
null => null, null => null,
}, },
spot: spot, spot: spot,
), );
); if (sheetShowsAsDialog(context)) {
return showDialog<void>(
context: context,
builder: (context) => Dialog(
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: bar,
),
),
);
}
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
elevation: 0,
// Lighter than a sheet's: the list stays in view behind it.
barrierColor: Colors.black26,
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(
8,
0,
8,
MediaQuery.viewInsetsOf(context).bottom + 8,
),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: bar,
),
),
);
}
/// Quick add's bar, for tests to find.
typedef QuickAddBar = _QuickAdd;
/// The key of quick add's title field. /// The key of quick add's title field.
const quickAddTitleKey = ValueKey('quick-add-title'); const quickAddTitleKey = ValueKey('quick-add-title');
@ -377,97 +416,114 @@ class _QuickAddState() extends State<_QuickAdd> {
}; };
final typing = _title.text.trim().isNotEmpty; final typing = _title.text.trim().isNotEmpty;
final suggestions = _suggestions(l10n, state); final suggestions = _suggestions(l10n, state);
return SheetScaffold( final scheme = theme.colorScheme;
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd, const borderless = InputDecoration(
hasSnackBars: true, border: InputBorder.none,
body: Column( enabledBorder: InputBorder.none,
mainAxisSize: MainAxisSize.min, focusedBorder: InputBorder.none,
crossAxisAlignment: CrossAxisAlignment.stretch, filled: false,
children: [ isDense: true,
// The down arrow opens the description, as Todoist's. contentPadding: EdgeInsets.symmetric(vertical: 8),
CallbackShortcuts( );
bindings: { return Semantics(
const SingleActivator(LogicalKeyboardKey.arrowDown): _describe, label: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
}, container: true,
child: TextField( child: Padding(
key: quickAddTitleKey, padding: const EdgeInsets.fromLTRB(16, 8, 12, 12),
controller: _title, child: Column(
focusNode: _focus, mainAxisSize: MainAxisSize.min,
autofocus: true, crossAxisAlignment: CrossAxisAlignment.stretch,
textInputAction: TextInputAction.done, children: [
textCapitalization: TextCapitalization.sentences, Center(
decoration: InputDecoration( child: Container(
hintText: l10n.taskTitleHint, width: 32,
helperText: parsed.tokens.isEmpty height: 4,
? null decoration: BoxDecoration(
: l10n.quickAddTokenHint, color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
), borderRadius: BorderRadius.circular(2),
onTap: _tapped, ),
onSubmitted: (_) => _add(),
),
),
if (suggestions.isNotEmpty)
Material(
key: quickAddSuggestionsKey,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final item in suggestions)
ListTile(
dense: true,
leading: Icon(switch (_typing()?.symbol) {
'@' => LucideIcons.tag,
_ when item.id == state.inboxId => LucideIcons.inbox,
_ => LucideIcons.hash,
}),
title: Text(item.name),
onTap: () => _complete(item),
),
],
), ),
), ),
if (_describing) // The down arrow opens the description, as Todoist's.
TextField( CallbackShortcuts(
controller: _description, bindings: {
focusNode: _descriptionFocus, const SingleActivator(LogicalKeyboardKey.arrowDown): _describe,
minLines: 1, },
maxLines: 4, child: TextField(
textCapitalization: TextCapitalization.sentences, key: quickAddTitleKey,
decoration: InputDecoration(hintText: l10n.taskDescriptionHint), controller: _title,
focusNode: _focus,
autofocus: true,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.sentences,
style: theme.textTheme.titleLarge,
decoration: borderless.copyWith(
hintText: l10n.taskTitleHint,
helperText: parsed.tokens.isEmpty
? null
: l10n.quickAddTokenHint,
),
onTap: _tapped,
onSubmitted: (_) => _add(),
),
), ),
// The chips come once there is something to add. if (suggestions.isNotEmpty)
AnimatedSize( Material(
duration: const Duration(milliseconds: 150), key: quickAddSuggestionsKey,
alignment: Alignment.topCenter, color: scheme.surfaceContainerHighest,
child: !typing borderRadius: BorderRadius.circular(12),
? const SizedBox(width: double.infinity) clipBehavior: Clip.antiAlias,
: Padding( child: Column(
padding: const EdgeInsets.only(top: 12), mainAxisSize: MainAxisSize.min,
child: Wrap( children: [
for (final item in suggestions)
ListTile(
dense: true,
leading: Icon(switch (_typing()?.symbol) {
'@' => LucideIcons.tag,
_ when item.id == state.inboxId => LucideIcons.inbox,
_ => LucideIcons.hash,
}),
title: Text(item.name),
onTap: () => _complete(item),
),
],
),
),
if (_describing)
TextField(
controller: _description,
focusNode: _descriptionFocus,
minLines: 1,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: borderless.copyWith(
hintText: l10n.taskDescriptionHint,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8, spacing: 8,
runSpacing: 4,
children: [ children: [
ActionChip( _more(context, deadline: deadline),
avatar: Icon( QuickAddButton(
date?.repeat == null icon: date?.repeat == null
? LucideIcons.calendar ? LucideIcons.calendar
: LucideIcons.repeat, : LucideIcons.repeat,
color: switch (date) { tooltip: l10n.dateTitle,
final date? => dateColor( color: switch (date) {
context, final date? => dateColor(context, date.day, today),
date.day, null => null,
today, },
), label: switch (date) {
null => null,
},
),
label: Text(switch (date) {
final date? => dateText(context, date, today), final date? => dateText(context, date, today),
null => l10n.dateTitle, null => null,
}), },
onPressed: () async { onPressed: () async {
if (await pickTaskDate(context, current: date) if (await pickTaskDate(context, current: date)
case (:final date)) { case (:final date)) {
@ -482,16 +538,22 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
ActionChip( if (deadline != null)
avatar: Icon( QuickAddButton(
LucideIcons.flag, icon: LucideIcons.target,
color: priorityColor(context, priority), tooltip: l10n.deadlineTitle,
), label: l10n.deadlineOn(
label: Text( dayText(context, deadline, today),
priority == Priority.none ),
? l10n.priorityTitle onPressed: () => _pickDeadline(deadline),
: priorityText(l10n, priority),
), ),
QuickAddButton(
icon: LucideIcons.flag,
tooltip: l10n.priorityTitle,
color: priorityColor(context, priority),
label: priority == Priority.none
? null
: priorityText(l10n, priority),
onPressed: () async { onPressed: () async {
if (await pickPriority(context, priority) if (await pickPriority(context, priority)
case final picked?) { case final picked?) {
@ -500,18 +562,31 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
QuickAddButton(
icon: LucideIcons.tag,
tooltip: l10n.labelsTitle,
label: labelCount == 0
? null
: l10n.labelsCount(labelCount),
onPressed: () async {
if (await pickLabels(context, [...labelIds])
case final labels?) {
_dropTokens(const {QuickAddKind.label});
setState(() => _labelIds = labels);
}
},
),
if (widget.parentId == null) if (widget.parentId == null)
ActionChip( QuickAddButton(
avatar: Icon( icon: project?.inbox ?? true
project?.inbox ?? true ? LucideIcons.inbox
? LucideIcons.inbox : LucideIcons.hash,
: LucideIcons.hash, tooltip: l10n.projectPickTitle,
), label: switch (project) {
label: Text(switch (project) {
final project? when !project.inbox => final project? when !project.inbox =>
project.content!.name, project.content!.name,
_ => l10n.shellTabInbox, _ => l10n.shellTabInbox,
}), },
onPressed: () async { onPressed: () async {
if (await pickProject(context, projectId) if (await pickProject(context, projectId)
case final id?) { case final id?) {
@ -525,42 +600,20 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
labelCount == 0
? l10n.labelsTitle
: l10n.labelsCount(labelCount),
),
onPressed: () async {
if (await pickLabels(context, [...labelIds])
case final labels?) {
_dropTokens(const {QuickAddKind.label});
setState(() => _labelIds = labels);
}
},
),
if (deadline != null)
ActionChip(
avatar: const Icon(LucideIcons.target),
label: Text(
l10n.deadlineOn(
dayText(context, deadline, today),
),
),
onPressed: () => _pickDeadline(deadline),
),
_more(context, deadline: deadline),
], ],
), ),
), ),
), ),
], const SizedBox(width: 8),
), IconButton.filled(
footer: FilledButton.icon( tooltip: l10n.taskAdd,
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.arrowUp),
label: Text(l10n.taskAdd), onPressed: typing && parsed.title.isNotEmpty ? _add : null,
onPressed: parsed.title.isEmpty ? null : _add, ),
],
),
],
),
), ),
); );
} }
@ -592,6 +645,57 @@ class _QuickAddState() extends State<_QuickAdd> {
), ),
], ],
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
}
/// One of quick add's boxed buttons (the developer's review, 3 Nov 2026,
/// as Todoist's): its [icon] alone while nothing is set, named by
/// [tooltip]; with what is set as its [label] beside it.
class const QuickAddButton({
required final IconData icon,
required final String tooltip,
required final VoidCallback onPressed,
final String? label,
final Color? color,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final shown = color ?? scheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Material(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: shown),
if (label case final label?) ...[
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: color,
),
),
],
],
),
),
),
),
); );
} }
} }

View file

@ -1,5 +1,6 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart'; import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/widget/title_with_help.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
@ -352,8 +353,10 @@ class _RepeatEditorState() extends State<_RepeatEditor> {
), ),
), ),
SwitchListTile( SwitchListTile(
title: Text(l10n.repeatCountFromCompletion), title: TitleWithHelp(
subtitle: Text(l10n.repeatCountFromCompletionHint), l10n.repeatCountFromCompletion,
l10n.repeatCountFromCompletionHint,
),
value: _fromCompletion, value: _fromCompletion,
onChanged: (on) => setState(() => _fromCompletion = on), onChanged: (on) => setState(() => _fromCompletion = on),
), ),

View file

@ -0,0 +1,32 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// [child], a list of tasks, pulled down to sync now (the developer's
/// review, 3 Nov 2026, as Learn's lists): the spinner shows until the sync
/// is over. Where [fill], [child] is not scrollable itself (a list's empty
/// message) and is pulled in a room of the list's whole height
/// ([PullRoom]).
class const TaskRefresh({
required final Widget child,
final bool fill = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => AppRefreshIndicator(
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
child: fill ? PullRoom(child: child) : child,
);
}
/// [child], not scrollable itself (a list's empty message), in a room of
/// the list's whole height that a [TaskRefresh] around it can pull.
class const PullRoom({required final Widget child, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [SliverFillRemaining(hasScrollBody: false, child: child)],
);
}

View file

@ -40,12 +40,111 @@ Future<void> openTaskSheet(BuildContext context, String id) async {
return; return;
} }
} }
await showAdaptiveSheet<void>( if (sheetShowsAsDialog(context)) {
context, await showAdaptiveSheet<void>(
builder: (context) => _TaskSheetHost(id: id), context,
builder: (context) => _TaskSheetHost(id: id),
);
return;
}
// On a phone partway up, as high as its content (at most 65 % of the
// window), and dragged up to the whole window, as Todoist's (the
// developer's review, 3 Nov 2026).
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
elevation: 0,
builder: (context) => _DraggableSheet(
builder: (scroll) => _SheetScroll(
controller: scroll,
child: _TaskSheetHost(id: id),
),
),
); );
} }
/// The key of the handle atop a task's sheet on a phone.
const taskSheetHandleKey = ValueKey('task-sheet-handle');
/// A task's sheet on a phone: partway up as high as its content, at most
/// [_partway] of the window, and up to the whole window dragged.
class const _DraggableSheet({
required final Widget Function(ScrollController scroll) builder,
}) extends StatefulWidget {
/// How far up it opens at most.
static const _partway = 0.65;
@override
State<_DraggableSheet> createState() => _DraggableSheetState();
}
class _DraggableSheetState() extends State<_DraggableSheet> {
final _sheet = DraggableScrollableController();
ScrollController? _scroll;
@override
void initState() {
super.initState();
// Once laid out, down to its content's height where that is less.
WidgetsBinding.instance.addPostFrameCallback((_) {
final scroll = _scroll;
if (!mounted || scroll == null || !scroll.hasClients) {
return;
}
final position = scroll.position;
final window = MediaQuery.sizeOf(context).height;
final content = position.maxScrollExtent + position.viewportDimension;
final size = (content / window).clamp(0.25, _DraggableSheet._partway);
if (_sheet.isAttached && size < _sheet.size) {
_sheet.jumpTo(size);
}
});
}
@override
void dispose() {
_sheet.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => DraggableScrollableSheet(
controller: _sheet,
expand: false,
initialChildSize: _DraggableSheet._partway,
minChildSize: 0.2,
snap: true,
builder: (context, scroll) {
_scroll = scroll;
return Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: widget.builder(scroll),
);
},
);
}
/// The scrolling of a [_DraggableSheet] for the task's sheet inside it,
/// whose whole content scrolls with it so that dragging it drags the sheet.
class const _SheetScroll({
required final ScrollController controller,
required super.child,
}) extends InheritedWidget {
static ScrollController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SheetScroll>()?.controller;
@override
bool updateShouldNotify(_SheetScroll oldWidget) =>
controller != oldWidget.controller;
}
/// A sheet's task, which a step of its path replaces in place. /// A sheet's task, which a step of its path replaces in place.
class const _TaskSheetHost({required final String id}) extends StatefulWidget { class const _TaskSheetHost({required final String id}) extends StatefulWidget {
@override @override
@ -239,6 +338,47 @@ class _TaskSheetState() extends State<_TaskSheet> {
Widget? heading, Widget? heading,
Widget? action, Widget? action,
}) { }) {
if (_SheetScroll.maybeOf(context) case final scroll? when !widget.inPane) {
final scheme = Theme.of(context).colorScheme;
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: ListView(
controller: scroll,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
children: [
Center(
key: taskSheetHandleKey,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 32,
height: 4,
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Expanded(
child:
heading ??
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
?action,
],
),
const SizedBox(height: 8),
body,
],
),
),
);
}
if (!widget.inPane) { if (!widget.inPane) {
return SheetScaffold( return SheetScaffold(
title: title, title: title,
@ -421,214 +561,271 @@ class _TaskSheetState() extends State<_TaskSheet> {
}, },
), ),
action: _menu(context, task, subTasks), action: _menu(context, task, subTasks),
// Its parts in rounded boxes, as Todoist's (the developer's review,
// 3 Nov 2026): the title, the description, the date, the reminders,
// then the priority, labels, deadline and project as chips, and the
// sub-tasks with one more to add.
body: Column( body: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( _Box(
children: [ child: Row(
TaskCheck(task: task), children: [
Expanded( TaskCheck(task: task),
child: TextField( Expanded(
controller: _title, child: TextField(
style: theme.textTheme.titleLarge, controller: _title,
textCapitalization: TextCapitalization.sentences, style: theme.textTheme.titleLarge,
decoration: InputDecoration( textCapitalization: TextCapitalization.sentences,
border: InputBorder.none, decoration: InputDecoration(
hintText: l10n.taskTitleHint, border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: l10n.taskTitleHint,
),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
), ),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
), ),
), ],
], ),
), ),
// The icon stays by the first line however long the // The icon stays by the first line however long the
// description grows (the developer's, 1 Nov). // description grows (the developer's, 1 Nov).
Row( _Box(
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const Padding( children: [
padding: EdgeInsets.fromLTRB(12, 12, 12, 0), const Padding(
child: Icon(LucideIcons.text), padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
), child: Icon(LucideIcons.text),
Expanded(
child: TextField(
controller: _description,
minLines: 1,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
hintText: l10n.taskDescriptionHint,
),
onTapOutside: (_) => unawaited(_saveText()),
), ),
), Expanded(
], child: TextField(
), controller: _description,
ListTile( minLines: 1,
leading: Icon( maxLines: 8,
LucideIcons.calendar, textCapitalization: TextCapitalization.sentences,
color: switch (content.date) { decoration: InputDecoration(
final date? => dateColor(context, date.day, today), border: InputBorder.none,
null => null, enabledBorder: InputBorder.none,
}, focusedBorder: InputBorder.none,
), filled: false,
title: Text(switch (content.date) { hintText: l10n.taskDescriptionHint,
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
ListTile(
leading: const Icon(LucideIcons.target),
title: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(dayText(context, day, today)),
null => l10n.deadlineNone,
}),
trailing: content.deadline == null
? null
: clear(() => _change((c) => c.copyWith(deadline: () => null))),
onTap: () async {
if (await pickDeadline(context, current: content.deadline) case (
:final day,
)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(remindersText(context, content, today)),
onTap: () async {
final timed = content.date?.time != null;
final chosen = await pickReminders(
context,
before: content.reminders,
at: content.reminderTimes,
timed: timed,
day: content.date?.day,
);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
final had =
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(
context,
).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change(
(c) => c.copyWith(
reminders: chosen.before,
reminderTimes: chosen.at,
),
);
},
),
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
title: Text(priorityText(l10n, content.priority)),
onTap: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
if (content.parentId == null)
ListTile(
leading: Icon(
project?.inbox ?? true ? LucideIcons.inbox : LucideIcons.hash,
),
title: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onTap: () async {
if (await pickProject(context, content.projectId) case final id?
when id != content.projectId) {
await _move(id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.tag),
title: Text(
content.labelIds.isEmpty
? l10n.labelsNoneSet
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onTap: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
subTasks.isEmpty
? l10n.subTasks
: l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
), ),
style: theme.textTheme.titleSmall?.copyWith(color: muted), onTapOutside: (_) => unawaited(_saveText()),
),
),
],
), ),
), ),
for (final sub in subTasks) _Box(
if (!(content.hideDoneSubTasks && sub.content.completed)) child: ListTile(
TaskTile(task: sub, selectable: false), leading: Icon(
Align( LucideIcons.calendar,
alignment: AlignmentDirectional.centerStart, color: switch (content.date) {
child: TextButton.icon( final date? => dateColor(context, date.day, today),
icon: const Icon(LucideIcons.plus), null => null,
label: Text(l10n.subTaskAdd), },
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
), ),
title: Text(switch (content.date) {
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
),
_Box(
child: ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(remindersText(context, content, today)),
onTap: () => _pickReminders(context, content),
),
),
_Box(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(12),
child: Row(
spacing: 8,
children: [
ActionChip(
avatar: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
label: Text(
content.priority == Priority.none
? l10n.priorityTitle
: priorityText(l10n, content.priority),
),
onPressed: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
content.labelIds.isEmpty
? l10n.labelsTitle
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onPressed: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.target),
label: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(
dayText(context, day, today),
),
null => l10n.deadlineTitle,
}),
onPressed: () async {
if (await pickDeadline(
context,
current: content.deadline,
)
case (:final day)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
if (content.parentId == null)
ActionChip(
avatar: Icon(
project?.inbox ?? true
? LucideIcons.inbox
: LucideIcons.hash,
),
label: Text(switch (project) {
final project? when !project.inbox =>
project.content!.name,
_ => l10n.shellTabInbox,
}),
onPressed: () async {
if (await pickProject(context, content.projectId)
case final id? when id != content.projectId) {
await _move(id);
}
},
),
],
),
),
),
_Box(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (subTasks.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
),
style: theme.textTheme.titleSmall?.copyWith(
color: muted,
),
),
),
for (final sub in subTasks)
if (!(content.hideDoneSubTasks && sub.content.completed))
TaskTile(task: sub, selectable: false),
],
TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.subTaskAdd),
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
),
),
],
), ),
), ),
], ],
), ),
); );
} }
/// Asks for the task's reminders and writes them, asking first to
/// remind where nothing was to be reminded of before.
Future<void> _pickReminders(BuildContext context, TaskContent content) async {
final l10n = AppLocalizations.of(context);
final timed = content.date?.time != null;
final chosen = await pickReminders(
context,
before: content.reminders,
at: content.reminderTimes,
timed: timed,
day: content.date?.day,
);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
final had =
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(context).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change(
(c) => c.copyWith(reminders: chosen.before, reminderTimes: chosen.at),
);
}
}
/// One part of a task's sheet in its rounded box (the developer's review,
/// 3 Nov 2026, as Todoist's).
class const _Box({required final Widget child}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: child,
),
);
} }
/// The key of the path atop a task's sheet, for tests to find it by. /// The key of the path atop a task's sheet, for tests to find it by.

View file

@ -33,11 +33,18 @@ import 'package:material_ui/material_ui.dart';
/// Where [later], it is a later time of a repeating task (a project's /// Where [later], it is a later time of a repeating task (a project's
/// Calendar, D17): muted, a recurrence's mark in place of the check, not /// Calendar, D17): muted, a recurrence's mark in place of the check, not
/// chosen nor swiped; a tap opens the task's sheet. /// chosen nor swiped; a tap opens the task's sheet.
///
/// Each row stands in a rounded box, as Todoist's (the developer's review,
/// 3 Nov 2026), the rows of one list together a group rounded at its ends
/// ([position], [taskGroup]); the box is the row's own surface, so its
/// colour while chosen stays inside it as the list scrolls under a pinned
/// heading.
class const TaskTile({ class const TaskTile({
required final OpenTask task, required final OpenTask task,
final bool showProject = false, final bool showProject = false,
final bool selectable = true, final bool selectable = true,
final bool later = false, final bool later = false,
final TaskRowPosition position = TaskRowPosition.alone,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -104,12 +111,13 @@ class const TaskTile({
}, },
), ),
]; ];
// At the row's end (the developer's review, 3 Nov 2026), as Todoist's.
final where = showProject && project != null final where = showProject && project != null
? Flexible( ? Flexible(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
@ -202,7 +210,7 @@ class const TaskTile({
: () => openTaskSheet(context, task.id), : () => openTaskSheet(context, task.id),
); );
if (selection == null) { if (selection == null) {
return tile; return _boxed(context, tile);
} }
Widget swipe(IconData icon, Color color, Alignment alignment) => ColoredBox( Widget swipe(IconData icon, Color color, Alignment alignment) => ColoredBox(
color: color, color: color,
@ -214,7 +222,7 @@ class const TaskTile({
), ),
), ),
); );
return Semantics( final row = Semantics(
customSemanticsActions: { customSemanticsActions: {
CustomSemanticsAction(label: l10n.swipeSelect): () => CustomSemanticsAction(label: l10n.swipeSelect): () =>
selection.toggle(task.id), selection.toggle(task.id),
@ -245,6 +253,32 @@ class const TaskTile({
child: tile, child: tile,
), ),
); );
return _boxed(context, row);
}
/// [row] in its rounded box, apart from the next by a hairline.
Widget _boxed(BuildContext context, Widget row) {
const outer = Radius.circular(16);
const inner = Radius.circular(4);
final top = switch (position) {
TaskRowPosition.alone || TaskRowPosition.first => outer,
_ => inner,
};
final bottom = switch (position) {
TaskRowPosition.alone || TaskRowPosition.last => outer,
_ => inner,
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 1, 12, 1),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: top, bottom: bottom),
),
child: row,
),
);
} }
/// Asks for the task's date and writes it. /// Asks for the task's date and writes it.
@ -301,3 +335,25 @@ class const TaskCheck({required final OpenTask task, super.key})
); );
} }
} }
/// Where a row stands in its list's group (the developer's review, 3 Nov
/// 2026): the group's box is rounded at its ends only.
enum TaskRowPosition() {
alone,
first,
middle,
last,
}
/// Where the [index]th of [length] rows stands in their group.
TaskRowPosition rowPosition(int index, int length) => switch ((index, length)) {
(_, 1) => TaskRowPosition.alone,
(0, _) => TaskRowPosition.first,
(final i, final n) when i == n - 1 => TaskRowPosition.last,
_ => TaskRowPosition.middle,
};
/// [rows] as one group, each told where it stands in it.
List<Widget> taskGroup(List<Widget Function(TaskRowPosition)> rows) => [
for (final (index, row) in rows.indexed) row(rowPosition(index, rows.length)),
];

View file

@ -3,6 +3,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart'; import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart'; import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart'; import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/widget/title_with_help.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart'; import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart'; import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart'; import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
@ -62,8 +63,10 @@ class const TaskSettingsCard({super.key}) extends StatelessWidget {
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.listRestart), secondary: const Icon(LucideIcons.listRestart),
title: Text(l10n.resetSubTasksTitle), title: TitleWithHelp(
subtitle: Text(l10n.resetSubTasksHint), l10n.resetSubTasksTitle,
l10n.resetSubTasksHint,
),
value: tasks.resetSubTasks, value: tasks.resetSubTasks,
onChanged: (on) => _write( onChanged: (on) => _write(
context, context,
@ -94,12 +97,11 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
children: [ children: [
ListTile( ListTile(
leading: const Icon(LucideIcons.alarmClockPlus), leading: const Icon(LucideIcons.alarmClockPlus),
title: Text(l10n.automaticReminderTitle), title: TitleWithHelp(
subtitle: Text( l10n.automaticReminderTitle,
'${automatic(reminders.automatic)}\n' l10n.automaticReminderHint,
'${l10n.automaticReminderHint}',
), ),
isThreeLine: true, subtitle: Text(automatic(reminders.automatic)),
onTap: () async { onTap: () async {
final picked = await _pick<int?>( final picked = await _pick<int?>(
context, context,

View file

@ -120,15 +120,21 @@ class _DayAgendaState() extends State<DayAgenda> {
today.daysUntil(WeekStrip.weekOf(selected, firstWeekday).plus(6)) + 1, today.daysUntil(WeekStrip.weekOf(selected, firstWeekday).plus(6)) + 1,
); );
final (:overdue, :days, :busy) = widget.calendarFor(reach); final (:overdue, :days, :busy) = widget.calendarFor(reach);
Widget row(CalendarEntry entry) => entry.occurrence Widget row(CalendarEntry entry, TaskRowPosition position) =>
entry.occurrence
? TaskTile( ? TaskTile(
task: entry.task, task: entry.task,
showProject: widget.showProject, showProject: widget.showProject,
later: true, later: true,
position: position,
) )
: DraggableTask( : DraggableTask(
task: entry.task, task: entry.task,
child: TaskTile(task: entry.task, showProject: widget.showProject), child: TaskTile(
task: entry.task,
showProject: widget.showProject,
position: position,
),
); );
return Column( return Column(
children: [ children: [
@ -150,6 +156,7 @@ class _DayAgendaState() extends State<DayAgenda> {
// few weeks of tasks, not thousands. // few weeks of tasks, not thousands.
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _scroll, controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context), bottom: 88 + FloatingNavigationBar.coverOf(context),
), ),
@ -164,8 +171,11 @@ class _DayAgendaState() extends State<DayAgenda> {
child: Text(l10n.reschedule), child: Text(l10n.reschedule),
), ),
), ),
for (final task in overdue) ...taskGroup([
row((task: task, occurrence: false)), for (final task in overdue)
(position) =>
row((task: task, occurrence: false), position),
]),
], ],
// The add button dropped on a day adds a task for it, a // The add button dropped on a day adds a task for it, a
// row held and dropped there moves to it (D16). // row held and dropped there moves to it (D16).
@ -192,7 +202,10 @@ class _DayAgendaState() extends State<DayAgenda> {
), ),
), ),
), ),
for (final entry in entries) row(entry), ...taskGroup([
for (final entry in entries)
(position) => row(entry, position),
]),
], ],
), ),
), ),

Some files were not shown because too many files have changed in this diff Show more