feat(todo): rows in rounded boxes, Today as Todoist's, lists pulled to sync, a project's own colour
From the developer's review of 3 Nov 2026 (ADR 0033, D19 to D21):
- Today's tab shows today's day in its calendar, Browse the menu's lines,
Display's button Lucide's square-menu.
- A task's row stands in a rounded box, the rows of a list a group rounded
at its ends; the box is the row's own surface, so a chosen row's colour no
longer shows under Upcoming's pinned week strip as the list scrolls. Its
project and section end the line of its date at the row's right edge.
- Today has at most two groups: what is overdue, folding under its heading,
and today's, headed "Tuesday, 3 Nov" ("Dienstag, 3. Nov."), without a
"· Today" after it; Upcoming's days take the same form.
- Upcoming's way back to today shows whenever another day is picked.
- Today, Upcoming, the Inbox and every project pulled down sync now, an
empty list too, as Learn's.
- A project's screen washes in its own colour, as a studyset's in Learn.
- "Add section" in place of "New section".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
55fc245859
commit
753527aed3
17 changed files with 518 additions and 64 deletions
|
|
@ -60,7 +60,7 @@
|
|||
"projectGone": "Dieses Projekt wurde gelöscht.",
|
||||
"projectMenu": "Mehr",
|
||||
"projectPickTitle": "Projekt",
|
||||
"sectionAdd": "Neuer Abschnitt",
|
||||
"sectionAdd": "Abschnitt hinzufügen",
|
||||
"sectionEdit": "Abschnitt umbenennen",
|
||||
"sectionDelete": "Abschnitt löschen",
|
||||
"sectionDeleteTitle": "Abschnitt löschen?",
|
||||
|
|
@ -243,5 +243,8 @@
|
|||
"snoozeTitle": "Schlummern für",
|
||||
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
|
||||
"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.",
|
||||
"projectMenu": "More",
|
||||
"projectPickTitle": "Project",
|
||||
"sectionAdd": "New section",
|
||||
"sectionAdd": "Add section",
|
||||
"sectionEdit": "Rename section",
|
||||
"sectionDelete": "Delete section",
|
||||
"sectionDeleteTitle": "Delete the section?",
|
||||
|
|
@ -778,5 +778,11 @@
|
|||
"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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New section'**
|
||||
/// **'Add section'**
|
||||
String get sectionAdd;
|
||||
|
||||
/// No description provided for @sectionEdit.
|
||||
|
|
@ -1563,6 +1563,24 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{count} more'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get projectPickTitle => 'Projekt';
|
||||
|
||||
@override
|
||||
String get sectionAdd => 'Neuer Abschnitt';
|
||||
String get sectionAdd => 'Abschnitt hinzufügen';
|
||||
|
||||
@override
|
||||
String get sectionEdit => 'Abschnitt umbenennen';
|
||||
|
|
@ -966,4 +966,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String monthMore(int count) {
|
||||
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';
|
||||
|
||||
@override
|
||||
String get sectionAdd => 'New section';
|
||||
String get sectionAdd => 'Add section';
|
||||
|
||||
@override
|
||||
String get sectionEdit => 'Rename section';
|
||||
|
|
@ -965,6 +965,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String monthMore(int count) {
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ class const DisplayButton({required final DisplayTarget target, super.key})
|
|||
icon: Badge(
|
||||
smallSize: 8,
|
||||
isLabelVisible: changed,
|
||||
child: const Icon(LucideIcons.slidersHorizontal),
|
||||
child: const Icon(LucideIcons.squareMenu),
|
||||
),
|
||||
onPressed: () => showDisplaySheet(context, target),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/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_refresh.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -57,7 +58,7 @@ class const ProjectScreenBody({
|
|||
const KeysResetBanner(),
|
||||
Expanded(
|
||||
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
|
||||
// account's keys, or still on its way here.
|
||||
null
|
||||
|
|
@ -98,7 +99,9 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
);
|
||||
}
|
||||
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,
|
||||
projectId: projectId,
|
||||
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/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_refresh.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/tabs/today_tab.dart';
|
||||
|
|
@ -73,7 +74,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
if (loose.isEmpty &&
|
||||
sections.isEmpty &&
|
||||
!state.tasks.any((t) => t.content.projectId == projectId)) {
|
||||
return Center(
|
||||
return PullRoom(
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.listTodo,
|
||||
title: l10n.projectEmptyTitle,
|
||||
|
|
@ -117,6 +118,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverList.list(
|
||||
children: [
|
||||
|
|
@ -220,7 +222,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
return PullRoom(
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.listFilter,
|
||||
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],
|
||||
);
|
||||
return CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
for (final (index, group) in groups.indexed)
|
||||
SliverMainAxisGroup(
|
||||
|
|
@ -260,8 +263,10 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
),
|
||||
SliverList.builder(
|
||||
itemCount: group.tasks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
TaskTile(task: group.tasks[index]),
|
||||
itemBuilder: (context, index) => TaskTile(
|
||||
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,
|
||||
tasks: group.tasks,
|
||||
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(
|
||||
BuildContext context,
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
/// Calendar, D17): muted, a recurrence's mark in place of the check, not
|
||||
/// 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({
|
||||
required final OpenTask task,
|
||||
final bool showProject = false,
|
||||
final bool selectable = true,
|
||||
final bool later = false,
|
||||
final TaskRowPosition position = TaskRowPosition.alone,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@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
|
||||
? Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
|
|
@ -202,7 +210,7 @@ class const TaskTile({
|
|||
: () => openTaskSheet(context, task.id),
|
||||
);
|
||||
if (selection == null) {
|
||||
return tile;
|
||||
return _boxed(context, tile);
|
||||
}
|
||||
Widget swipe(IconData icon, Color color, Alignment alignment) => ColoredBox(
|
||||
color: color,
|
||||
|
|
@ -214,7 +222,7 @@ class const TaskTile({
|
|||
),
|
||||
),
|
||||
);
|
||||
return Semantics(
|
||||
final row = Semantics(
|
||||
customSemanticsActions: {
|
||||
CustomSemanticsAction(label: l10n.swipeSelect): () =>
|
||||
selection.toggle(task.id),
|
||||
|
|
@ -245,6 +253,32 @@ class const TaskTile({
|
|||
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.
|
||||
|
|
@ -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)),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -120,15 +120,21 @@ class _DayAgendaState() extends State<DayAgenda> {
|
|||
today.daysUntil(WeekStrip.weekOf(selected, firstWeekday).plus(6)) + 1,
|
||||
);
|
||||
final (:overdue, :days, :busy) = widget.calendarFor(reach);
|
||||
Widget row(CalendarEntry entry) => entry.occurrence
|
||||
Widget row(CalendarEntry entry, TaskRowPosition position) =>
|
||||
entry.occurrence
|
||||
? TaskTile(
|
||||
task: entry.task,
|
||||
showProject: widget.showProject,
|
||||
later: true,
|
||||
position: position,
|
||||
)
|
||||
: DraggableTask(
|
||||
task: entry.task,
|
||||
child: TaskTile(task: entry.task, showProject: widget.showProject),
|
||||
child: TaskTile(
|
||||
task: entry.task,
|
||||
showProject: widget.showProject,
|
||||
position: position,
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
|
|
@ -150,6 +156,7 @@ class _DayAgendaState() extends State<DayAgenda> {
|
|||
// few weeks of tasks, not thousands.
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
|
|
@ -164,8 +171,11 @@ class _DayAgendaState() extends State<DayAgenda> {
|
|||
child: Text(l10n.reschedule),
|
||||
),
|
||||
),
|
||||
...taskGroup([
|
||||
for (final task in overdue)
|
||||
row((task: task, occurrence: false)),
|
||||
(position) =>
|
||||
row((task: task, occurrence: false), position),
|
||||
]),
|
||||
],
|
||||
// The add button dropped on a day adds a task for it, a
|
||||
// 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),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
76
apps/todo/halkyon_todo_flutter/lib/src/tabs/today_icon.dart
Normal file
76
apps/todo/halkyon_todo_flutter/lib/src/tabs/today_icon.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Today's tab icon (the developer's review, 3 Nov 2026, as Todoist's): a
|
||||
/// blank calendar with today's day of the month in it, as Lucide's
|
||||
/// `calendar-1` holds a 1, drawn anew at midnight.
|
||||
class const TodayIcon({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<TodayIcon> createState() => _TodayIconState();
|
||||
}
|
||||
|
||||
class _TodayIconState() extends State<TodayIcon> {
|
||||
Timer? _midnight;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_wait();
|
||||
}
|
||||
|
||||
/// Draws the icon again just after the next midnight, and waits again.
|
||||
void _wait() {
|
||||
final now = clock.now();
|
||||
final next = DateTime(now.year, now.month, now.day + 1);
|
||||
_midnight = Timer(next.difference(now) + const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_wait();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_midnight?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = IconTheme.of(context);
|
||||
final size = icon.size ?? 24;
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.calendar),
|
||||
// Lucide's calendar: its box from 4 to 22 of 24 units, the line
|
||||
// under its rings at 10; the day centred in what is below it.
|
||||
Positioned(
|
||||
top: size * 10 / 24,
|
||||
bottom: size * 2 / 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${clock.now().day}',
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: TextStyle(
|
||||
color: icon.color,
|
||||
fontSize: size * 0.4,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.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_refresh.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -16,11 +17,21 @@ import 'package:intl/intl.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Today (ADR 0033, D2, D8), where the app opens: what is overdue first,
|
||||
/// with Reschedule moving all of it to a day at once, then what is due
|
||||
/// today, each with its project; a button adds a task for today. A reset of
|
||||
/// the person's keys waits above it (`KeysResetBanner`).
|
||||
class const TodayTab({super.key}) extends StatelessWidget {
|
||||
/// Today (ADR 0033, D2, D8), where the app opens: at most two groups, as
|
||||
/// Todoist's (the developer's review, 3 Nov 2026): what is overdue, folding
|
||||
/// under its heading, with Reschedule moving all of it to a day at once,
|
||||
/// then today's, headed by the day alone; each with its project. A button
|
||||
/// adds a task for today. A reset of the person's keys waits above it
|
||||
/// (`KeysResetBanner`).
|
||||
class const TodayTab({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<TodayTab> createState() => _TodayTabState();
|
||||
}
|
||||
|
||||
class _TodayTabState() extends State<TodayTab> {
|
||||
/// Whether what is overdue is folded away.
|
||||
bool _overdueFolded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -43,34 +54,71 @@ class const TodayTab({super.key}) extends StatelessWidget {
|
|||
child: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: overdue.isEmpty && due.isEmpty
|
||||
? Center(
|
||||
? TaskRefresh(
|
||||
fill: true,
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.calendarCheck,
|
||||
title: l10n.todayEmptyTitle,
|
||||
hint: l10n.todayEmptyHint,
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
: TaskRefresh(
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
children: [
|
||||
if (overdue.isNotEmpty)
|
||||
if (overdue.isNotEmpty) ...[
|
||||
DayHeading(
|
||||
title: l10n.overdue,
|
||||
action: TextButton(
|
||||
onPressed: () => reschedule(context, overdue),
|
||||
action: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
reschedule(context, overdue),
|
||||
child: Text(l10n.reschedule),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _overdueFolded
|
||||
? l10n.overdueShow
|
||||
: l10n.overdueHide,
|
||||
icon: Icon(
|
||||
_overdueFolded
|
||||
? LucideIcons.chevronDown
|
||||
: LucideIcons.chevronUp,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _overdueFolded = !_overdueFolded,
|
||||
),
|
||||
),
|
||||
for (final task in overdue)
|
||||
TaskTile(task: task, showProject: true),
|
||||
DayHeading(title: dayHeading(context, today, today)),
|
||||
for (final task in due)
|
||||
TaskTile(task: task, showProject: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_overdueFolded)
|
||||
...taskGroup([
|
||||
for (final task in overdue)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
DayHeading(title: dayTitle(context, today)),
|
||||
...taskGroup([
|
||||
for (final task in due)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -108,13 +156,18 @@ class const DayHeading({
|
|||
}
|
||||
}
|
||||
|
||||
/// [day]'s heading, seen on [today]: its weekday, day and month, and Today
|
||||
/// or Tomorrow where it is.
|
||||
String dayHeading(BuildContext context, Day day, Day today) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final date = DateFormat.MMMMEEEEd(
|
||||
/// [day] as a heading names it, as Todoist's ("Tuesday, 3 Nov"; the
|
||||
/// developer's review, 3 Nov 2026): its weekday, day and short month.
|
||||
String dayTitle(BuildContext context, Day day) => DateFormat(
|
||||
AppLocalizations.of(context).dayHeadingPattern,
|
||||
Localizations.localeOf(context).toString(),
|
||||
).format(day.toDate());
|
||||
|
||||
/// [day]'s heading, seen on [today]: [dayTitle], and Today or Tomorrow
|
||||
/// where it is.
|
||||
String dayHeading(BuildContext context, Day day, Day today) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final date = dayTitle(context, day);
|
||||
return switch (today.daysUntil(day)) {
|
||||
0 => l10n.dayHeadingNamed(date, l10n.dayToday),
|
||||
1 => l10n.dayHeadingNamed(date, l10n.dayTomorrow),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -65,7 +66,7 @@ class _TodoShellState() extends State<TodoShell> {
|
|||
),
|
||||
(
|
||||
label: l10n.shellTabToday,
|
||||
icon: const Icon(LucideIcons.calendarCheck),
|
||||
icon: const TodayIcon(),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
|
|
@ -78,7 +79,7 @@ class _TodoShellState() extends State<TodoShell> {
|
|||
// The cog to Settings is atop Browse: the count of accounts
|
||||
// waiting for whoever operates the instance shows on its tab.
|
||||
icon: const WaitingAccountsBadge(
|
||||
child: Icon(LucideIcons.layoutList),
|
||||
child: Icon(LucideIcons.menu),
|
||||
),
|
||||
selectedIcon: null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,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/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_refresh.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/day_agenda.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/three_day_grid.dart';
|
||||
|
|
@ -72,11 +73,13 @@ class _UpcomingTabState() extends State<UpcomingTab> {
|
|||
body: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: switch (upcoming.layout) {
|
||||
UpcomingLayout.agenda => DayAgenda(
|
||||
UpcomingLayout.agenda => TaskRefresh(
|
||||
child: DayAgenda(
|
||||
showProject: true,
|
||||
onSelected: select,
|
||||
calendarFor: calendarFor,
|
||||
),
|
||||
),
|
||||
UpcomingLayout.threeDay => ThreeDayGrid(
|
||||
coloring: upcoming.coloring,
|
||||
onStart: select,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// The month's name, or its handle dragged down, unfolds the whole month in
|
||||
/// place beneath it, the arrows then going by months; the handle dragged up
|
||||
/// or the name again folds it (the developer's, 1 Nov, D14, as Todoist's).
|
||||
/// Once another week shows, a button beside the name goes back to today.
|
||||
/// Once another day than today is picked, a button beside the name goes
|
||||
/// back to today (the developer's review, 3 Nov 2026).
|
||||
class const WeekStrip({
|
||||
required final Day selected,
|
||||
required final Day today,
|
||||
|
|
@ -142,7 +143,7 @@ class _WeekStripState() extends State<WeekStrip> {
|
|||
onPressed: () => _setMonth(!_month),
|
||||
),
|
||||
),
|
||||
if (start != thisWeek)
|
||||
if (selected != today)
|
||||
IconButton(
|
||||
tooltip: l10n.weekToday,
|
||||
icon: const Icon(LucideIcons.calendar1),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.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_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/three_day_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -50,6 +53,9 @@ void main() {
|
|||
late MemorySealedRowGateway settingsRows;
|
||||
late MemoryReminderGateway reminders;
|
||||
|
||||
/// How many syncs ran.
|
||||
late int syncs;
|
||||
|
||||
tearDown(() async {
|
||||
await sync.dispose();
|
||||
keys.dispose();
|
||||
|
|
@ -63,8 +69,9 @@ void main() {
|
|||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
syncs = 0;
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
({required onMerge}) async => syncs++,
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
projectRows = MemorySealedRowGateway();
|
||||
|
|
@ -267,6 +274,51 @@ void main() {
|
|||
expect(find.widgetWithText(AppBar, 'Today'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("Today's tab shows today's day in its calendar, Browse the "
|
||||
"menu's lines (the developer's review, 3 Nov 2026)", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
final bar = find.byType(NavigationBar);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TodayIcon),
|
||||
matching: find.text('${DateTime.now().day}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: bar, matching: find.byType(TodayIcon)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: bar, matching: find.byIcon(LucideIcons.menu)),
|
||||
findsOneWidget,
|
||||
);
|
||||
await goTab(tester, 'Upcoming');
|
||||
expect(
|
||||
find.byIcon(LucideIcons.squareMenu),
|
||||
findsOneWidget,
|
||||
reason: "Display's button",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('every list of tasks pulled down syncs now, an empty one too '
|
||||
"(the developer's review, 3 Nov 2026)", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
||||
for (final tab in ['Today', 'Inbox', 'Upcoming']) {
|
||||
await goTab(tester, tab);
|
||||
final before = syncs;
|
||||
await tester.fling(
|
||||
find.byType(Scrollable).last,
|
||||
const Offset(0, 300),
|
||||
1000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(syncs, greaterThan(before), reason: tab);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('each tab says what it holds while it has nothing', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -1609,6 +1661,53 @@ void main() {
|
|||
tester.getTopRight(where).dx,
|
||||
greaterThan(tester.getTopRight(date).dx),
|
||||
);
|
||||
// At the row's end, as Todoist's, not halfway along (the developer's
|
||||
// review, 3 Nov 2026): only the place's icon and the padding after it.
|
||||
final row = find.widgetWithText(ListTile, 'Feed the dogs');
|
||||
expect(
|
||||
tester.getTopRight(row).dx - tester.getTopRight(where).dx,
|
||||
lessThan(48),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("Today's day is headed as Todoist heads it, without a "
|
||||
'Today after it; what is overdue folds under its heading', (
|
||||
tester,
|
||||
) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Pay the bill',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today.plus(-3)),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Water the plants',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text(DateFormat('EEEE, d MMM', 'en').format(today.toDate())),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('· Today'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Hide what is overdue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Pay the bill'), findsNothing);
|
||||
expect(find.text('Water the plants'), findsOneWidget);
|
||||
expect(find.text('Overdue'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Show what is overdue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Pay the bill'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Today puts what is overdue first; Reschedule moves it to '
|
||||
|
|
@ -1681,6 +1780,38 @@ void main() {
|
|||
expect(await tester.runAsync(() => tasks().all()), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("a project's screen washes in its own colour, as a "
|
||||
"studyset's in Learn; one without keeps Todo's strawberry", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
final projects = ProjectRepository(projectRows, keys);
|
||||
await tester.runAsync(() async {
|
||||
await projects.save(
|
||||
const ProjectContent(name: 'Garden', color: PaletteColor.pacific),
|
||||
);
|
||||
await projects.save(const ProjectContent(name: 'Plain'));
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
await goTab(tester, 'Browse');
|
||||
|
||||
await tester.tap(find.text('Garden'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
ScreenColor.of(tester.element(find.byType(TopGradient).last)),
|
||||
PaletteColor.pacific.background,
|
||||
);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Plain'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
ScreenColor.of(tester.element(find.byType(TopGradient).last)),
|
||||
PaletteColor.strawberry.background,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a task's sheet sets its priority, adds a sub-task the row "
|
||||
'counts, and deletes it with its sub-tasks after asking', (
|
||||
tester,
|
||||
|
|
@ -1917,6 +2048,19 @@ void main() {
|
|||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byTooltip('Add task'), findsNothing);
|
||||
expect(find.text('Water plants'), findsOneWidget, reason: 'kept');
|
||||
// Its colour is painted on the row's own box, inside the list, not
|
||||
// on the screen below it, where it showed under the pinned week
|
||||
// strip as the list scrolled (the developer's review, 3 Nov 2026).
|
||||
final surface = find
|
||||
.ancestor(
|
||||
of: find.widgetWithText(ListTile, 'Water plants'),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first;
|
||||
expect(
|
||||
find.ancestor(of: surface, matching: find.byType(TaskTile)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(row('Post letter'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -2547,13 +2691,28 @@ void main() {
|
|||
});
|
||||
|
||||
group("Upcoming's strip (ADR 0033, D8, D14)", () {
|
||||
testWidgets('once another week shows, a button beside the month goes back '
|
||||
'to today', (tester) async {
|
||||
testWidgets('once another day than today is picked, a button beside the '
|
||||
'month goes back to today', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Upcoming');
|
||||
expect(find.byTooltip('Back to today'), findsNothing);
|
||||
|
||||
// Another day of this week picked: the way back shows already.
|
||||
final strip = tester.element(find.byType(WeekStrip));
|
||||
final week = WeekStrip.weekOf(
|
||||
today,
|
||||
MaterialLocalizations.of(strip).firstDayOfWeekIndex,
|
||||
);
|
||||
if (today < week.plus(6)) {
|
||||
await tester.tap(find.byKey(WeekStrip.dayKey(today.plus(1))));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Back to today'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Back to today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Back to today'), findsNothing);
|
||||
}
|
||||
|
||||
await tester.tap(find.byTooltip('Following week'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(WeekStrip.dayKey(today)), findsNothing);
|
||||
|
|
|
|||
Loading…
Reference in a new issue