Merge branch 'todo-fb' into integration
This commit is contained in:
commit
82436ad030
27 changed files with 1618 additions and 561 deletions
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue