feat(todo): a project's Calendar as a month on wide windows (ADR 0033, D17)
From 720 pixels of the Calendar's own width on, a month grid in place of the agenda: the month's weeks from the week's first day, the days of the months beside it muted, each day with as many of its tasks as fit and the rest counted, opening the day's tasks in a sheet; past days with their overdue and done tasks. A task held and dropped on another day moves there (moveTask), a day's free space tapped adds a task on it, a line tapped opens its task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
0373f723e7
commit
0b6f79244f
11 changed files with 761 additions and 51 deletions
File diff suppressed because one or more lines are too long
|
|
@ -231,5 +231,6 @@
|
|||
"automaticReminderNone": "Keine",
|
||||
"snoozeTitle": "Schlummern für",
|
||||
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
|
||||
"reminderSnooze": "{count} Min. schlummern"
|
||||
"reminderSnooze": "{count} Min. schlummern",
|
||||
"monthMore": "{count} weitere"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -734,5 +734,14 @@
|
|||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"monthMore": "{count} more",
|
||||
"@monthMore": {
|
||||
"description": "A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1491,6 +1491,12 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Snooze {count} min'**
|
||||
String reminderSnooze(int count);
|
||||
|
||||
/// A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} more'**
|
||||
String monthMore(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -921,4 +921,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String reminderSnooze(int count) {
|
||||
return '$count Min. schlummern';
|
||||
}
|
||||
|
||||
@override
|
||||
String monthMore(int count) {
|
||||
return '$count weitere';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -920,6 +920,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String reminderSnooze(int count) {
|
||||
return 'Snooze $count min';
|
||||
}
|
||||
|
||||
@override
|
||||
String monthMore(int count) {
|
||||
return '$count more';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -53,35 +53,9 @@ ProjectCalendar calendarOf(
|
|||
day,
|
||||
};
|
||||
if (occurrences) {
|
||||
final end = today.plus(days);
|
||||
for (final task in tasks) {
|
||||
final content = task.content;
|
||||
if (content.completed) {
|
||||
continue;
|
||||
}
|
||||
if (content.date case final date? when date.repeat != null) {
|
||||
var repeat = date.repeat!;
|
||||
var next = repeat.next(date.day, today: today);
|
||||
for (
|
||||
var count = 0;
|
||||
next != null && next < end && count < _occurrencesAtMost;
|
||||
count++
|
||||
) {
|
||||
final day = next;
|
||||
repeat = repeat.counted();
|
||||
byDay[day]?.add((
|
||||
task: (
|
||||
id: task.id,
|
||||
content: content.copyWith(
|
||||
date: () => date.on(day).repeating(repeat),
|
||||
),
|
||||
),
|
||||
occurrence: true,
|
||||
));
|
||||
busy.add(day);
|
||||
next = repeat.next(day, today: day);
|
||||
}
|
||||
}
|
||||
for (final (day, entry) in _laterTimes(tasks, today, today.plus(days))) {
|
||||
byDay[day]?.add(entry);
|
||||
busy.add(day);
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
|
@ -93,3 +67,93 @@ ProjectCalendar calendarOf(
|
|||
busy: busy,
|
||||
);
|
||||
}
|
||||
|
||||
/// The later times of each repeating task of [tasks] not done, from the
|
||||
/// first as if done [today] (`Repeat.next`) to before [end], each with its
|
||||
/// day: the task as it will be then.
|
||||
Iterable<(Day, CalendarEntry)> _laterTimes(
|
||||
List<OpenTask> tasks,
|
||||
Day today,
|
||||
Day end,
|
||||
) sync* {
|
||||
for (final task in tasks) {
|
||||
final content = task.content;
|
||||
if (content.completed) {
|
||||
continue;
|
||||
}
|
||||
if (content.date case final date? when date.repeat != null) {
|
||||
var repeat = date.repeat!;
|
||||
var next = repeat.next(date.day, today: today);
|
||||
for (
|
||||
var count = 0;
|
||||
next != null && next < end && count < _occurrencesAtMost;
|
||||
count++
|
||||
) {
|
||||
final day = next;
|
||||
repeat = repeat.counted();
|
||||
yield (
|
||||
day,
|
||||
(
|
||||
task: (
|
||||
id: task.id,
|
||||
content: content.copyWith(
|
||||
date: () => date.on(day).repeating(repeat),
|
||||
),
|
||||
),
|
||||
occurrence: true,
|
||||
),
|
||||
);
|
||||
next = repeat.next(day, today: day);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The weeks a month grid shows for the month of [month] (D17): the first
|
||||
/// day of each, on [firstWeekday] (`DateTime`'s, or 0 for Sunday), from
|
||||
/// the week of the month's first day to the week of its last.
|
||||
List<Day> monthWeeks(Day month, int firstWeekday) {
|
||||
final first = Day(month.year, month.month, 1);
|
||||
final last = Day(month.year, month.month, first.daysInMonth);
|
||||
final weekday = firstWeekday == 0 ? DateTime.sunday : firstWeekday;
|
||||
return [
|
||||
for (
|
||||
var week = first.plus(-((first.weekday - weekday + 7) % 7));
|
||||
!(last < week);
|
||||
week = week.plus(7)
|
||||
)
|
||||
week,
|
||||
];
|
||||
}
|
||||
|
||||
/// [tasks] (those the project's Display keeps) on each day from [from] to
|
||||
/// before [to], as a month grid shows them (D17): past days too, with
|
||||
/// their tasks overdue or done, a day's by time or [order]; where
|
||||
/// [occurrences], the later times of repeating tasks not done, from
|
||||
/// [today] on, as [calendarOf]'s.
|
||||
Map<Day, List<CalendarEntry>> monthOf(
|
||||
List<OpenTask> tasks,
|
||||
Day today, {
|
||||
required Day from,
|
||||
required Day to,
|
||||
bool occurrences = false,
|
||||
int Function(OpenTask a, OpenTask b) order = byTime,
|
||||
}) {
|
||||
final days = {
|
||||
for (var day = from; day < to; day = day.plus(1)) day: <CalendarEntry>[],
|
||||
};
|
||||
for (final task in tasks) {
|
||||
if (task.content.date case TaskDate(:final day)) {
|
||||
days[day]?.add((task: task, occurrence: false));
|
||||
}
|
||||
}
|
||||
if (occurrences) {
|
||||
for (final (day, entry) in _laterTimes(tasks, today, to)) {
|
||||
days[day]?.add(entry);
|
||||
}
|
||||
}
|
||||
for (final entries in days.values) {
|
||||
entries.sort((a, b) => order(a.task, b.task));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
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_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.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';
|
||||
|
||||
/// How tall a task's line in a day is.
|
||||
const _lineHeight = 22.0;
|
||||
|
||||
/// How tall a day's number is, above its tasks.
|
||||
const _dayNumberHeight = 26.0;
|
||||
|
||||
/// A project's Calendar as a month (ADR 0033, D17, as Todoist's on the
|
||||
/// desktop), where it is wide enough: the month's weeks from the week's
|
||||
/// first day ([firstWeekdayOf]), each day with as many of its tasks
|
||||
/// ([monthOf] over [entriesFor]) as fit and the rest counted, opening all
|
||||
/// of them in a sheet; the days of the months beside it muted. A task held
|
||||
/// and dropped on another day moves there ([DayDropArea]), a day's free
|
||||
/// space tapped adds a task on it in [projectId], a task tapped opens it.
|
||||
/// [trailing] stands beside the month's name (*No date*).
|
||||
class const MonthGrid({
|
||||
required final String projectId,
|
||||
required final Map<Day, List<CalendarEntry>> Function(Day from, Day to)
|
||||
entriesFor,
|
||||
final Widget? trailing,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// The key of [day]'s cell.
|
||||
static Key dayKey(Day day) => ValueKey('month-day-$day');
|
||||
|
||||
/// The key of the line of [taskId] on [day].
|
||||
static Key chipKey(String taskId, Day day) =>
|
||||
ValueKey('month-task-$taskId-$day');
|
||||
|
||||
/// The key of the count of [day]'s tasks that don't fit.
|
||||
static Key moreKey(Day day) => ValueKey('month-more-$day');
|
||||
|
||||
@override
|
||||
State<MonthGrid> createState() => _MonthGridState();
|
||||
}
|
||||
|
||||
class _MonthGridState() extends State<MonthGrid> {
|
||||
/// The month shown, as its first day; none: today's.
|
||||
Day? _month;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final today = Day.of(clock.now());
|
||||
final thisMonth = Day(today.year, today.month, 1);
|
||||
final month = _month ?? thisMonth;
|
||||
void go(Day day) => setState(() => _month = Day(day.year, day.month, 1));
|
||||
final weeks = monthWeeks(month, firstWeekdayOf(context));
|
||||
final entries = widget.entriesFor(weeks.first, weeks.last.plus(7));
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
DateFormat.yMMMM(locale).format(month.toDate()),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (month != thisMonth)
|
||||
IconButton(
|
||||
tooltip: l10n.weekToday,
|
||||
icon: const Icon(LucideIcons.calendar1),
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: () => go(today),
|
||||
),
|
||||
const Spacer(),
|
||||
?widget.trailing,
|
||||
IconButton(
|
||||
tooltip: l10n.monthPrevious,
|
||||
icon: const Icon(LucideIcons.chevronLeft),
|
||||
onPressed: () =>
|
||||
go(Day.of(DateTime(month.year, month.month - 1))),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.monthNext,
|
||||
icon: const Icon(LucideIcons.chevronRight),
|
||||
onPressed: () =>
|
||||
go(Day.of(DateTime(month.year, month.month + 1))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (var index = 0; index < 7; index++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
DateFormat.E(
|
||||
locale,
|
||||
).format(weeks.first.plus(index).toDate()),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final week in weeks)
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < 7; index++)
|
||||
Expanded(
|
||||
child: _DayCell(
|
||||
key: MonthGrid.dayKey(week.plus(index)),
|
||||
day: week.plus(index),
|
||||
today: today,
|
||||
inMonth: week.plus(index).month == month.month,
|
||||
entries: entries[week.plus(index)] ?? const [],
|
||||
projectId: widget.projectId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A day of the month: its number, today's marked, then as many of its
|
||||
/// tasks as fit, the rest counted.
|
||||
class const _DayCell({
|
||||
required final Day day,
|
||||
required final Day today,
|
||||
required final bool inMonth,
|
||||
required final List<CalendarEntry> entries,
|
||||
required final String projectId,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isToday = day == today;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: inMonth ? null : scheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
right: BorderSide(color: scheme.outlineVariant),
|
||||
bottom: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: DayDropArea(
|
||||
day: day,
|
||||
projectId: projectId,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
unawaited(showQuickAdd(context, projectId: projectId, day: day)),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final room =
|
||||
((constraints.maxHeight - _dayNumberHeight - 4) / _lineHeight)
|
||||
.floor()
|
||||
.clamp(0, 99);
|
||||
final fits = entries.length <= room;
|
||||
final shown = fits ? entries : entries.take(room - 1).toList();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _dayNumberHeight,
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? scheme.primary : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
'${day.day}',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: isToday
|
||||
? scheme.onPrimary
|
||||
: inMonth
|
||||
? null
|
||||
: scheme.onSurfaceVariant,
|
||||
fontWeight: isToday ? FontWeight.w700 : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final entry in shown)
|
||||
SizedBox(
|
||||
height: _lineHeight,
|
||||
child: _TaskLine(
|
||||
key: MonthGrid.chipKey(entry.task.id, day),
|
||||
entry: entry,
|
||||
),
|
||||
),
|
||||
if (!fits && room > 0)
|
||||
SizedBox(
|
||||
height: _lineHeight,
|
||||
child: TextButton(
|
||||
key: MonthGrid.moreKey(day),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
textStyle: theme.textTheme.labelSmall,
|
||||
),
|
||||
onPressed: () => _showDay(context, day, entries),
|
||||
child: Text(
|
||||
l10n.monthMore(entries.length - shown.length),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A task's line in a day: its time where it has one and its title, its
|
||||
/// mark in its priority's colour, struck through once done, muted with the
|
||||
/// recurrence's mark for a later time; tapped it opens the task, held it
|
||||
/// is dragged, but for a later time.
|
||||
class const _TaskLine({required final CalendarEntry entry, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final content = entry.task.content;
|
||||
final muted = entry.occurrence || content.completed;
|
||||
final color = priorityColor(context, content.priority) ?? scheme.outline;
|
||||
final line = InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () => openTaskSheet(context, entry.task.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
entry.occurrence
|
||||
? LucideIcons.repeat
|
||||
: content.completed
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.circle,
|
||||
size: 11,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
[
|
||||
if (content.date?.time case final time?)
|
||||
timeText(context, time),
|
||||
content.title,
|
||||
].join(' '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted ? scheme.onSurfaceVariant : null,
|
||||
decoration: content.completed
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return entry.occurrence
|
||||
? line
|
||||
: DraggableTask(task: entry.task, child: line);
|
||||
}
|
||||
}
|
||||
|
||||
/// All of [day]'s tasks in a sheet, the later times of repeating ones as
|
||||
/// such.
|
||||
Future<void> _showDay(
|
||||
BuildContext context,
|
||||
Day day,
|
||||
List<CalendarEntry> entries,
|
||||
) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) {
|
||||
// Watched, so a task done or moved changes in the sheet.
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final current = {for (final task in state.tasks) task.id: task};
|
||||
return SheetScaffold(
|
||||
title: dayText(context, day, Day.of(clock.now())),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final entry in entries)
|
||||
if (entry.occurrence)
|
||||
TaskTile(task: entry.task, later: true)
|
||||
else if (current[entry.task.id] case final task?
|
||||
when task.content.date?.day == day)
|
||||
TaskTile(task: task),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
|
@ -17,12 +18,17 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// week strip and agenda ([DayAgenda]) over the tasks of [projectId] its
|
||||
/// Display keeps, sub-tasks too, a day's in its sorting (by time where it
|
||||
/// sorts by hand); where it asks, repeating tasks also on their later days.
|
||||
/// *No date* beside the month opens the tasks without one.
|
||||
/// *No date* beside the month opens the tasks without one. From
|
||||
/// [ProjectCalendarView.monthWidth] on, a month instead ([MonthGrid]).
|
||||
class const ProjectCalendarView({
|
||||
required final String projectId,
|
||||
required final ProjectView view,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// How wide the Calendar is at least to show a month: seven days of a
|
||||
/// little over a hundred pixels each, a desktop's window with its rail.
|
||||
static const monthWidth = 720.0;
|
||||
|
||||
/// The key of the button opening the tasks without a date.
|
||||
static const noDateKey = ValueKey('calendar-no-date');
|
||||
|
||||
|
|
@ -52,20 +58,37 @@ class const ProjectCalendarView({
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final kept = keptOf(context, state, projectId, view);
|
||||
return DayAgenda(
|
||||
projectId: projectId,
|
||||
trailing: TextButton(
|
||||
key: noDateKey,
|
||||
onPressed: () => _showNoDate(context, projectId, view),
|
||||
child: Text(l10n.calendarNoDate),
|
||||
),
|
||||
calendarFor: (days) => calendarOf(
|
||||
kept,
|
||||
Day.of(clock.now()),
|
||||
days: days,
|
||||
occurrences: view.occurrences,
|
||||
order: view.sorting == TaskSorting.manual ? byTime : view.compare,
|
||||
),
|
||||
final noDate = TextButton(
|
||||
key: noDateKey,
|
||||
onPressed: () => _showNoDate(context, projectId, view),
|
||||
child: Text(l10n.calendarNoDate),
|
||||
);
|
||||
final order = view.sorting == TaskSorting.manual ? byTime : view.compare;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth >= monthWidth
|
||||
? MonthGrid(
|
||||
projectId: projectId,
|
||||
trailing: noDate,
|
||||
entriesFor: (from, to) => monthOf(
|
||||
kept,
|
||||
Day.of(clock.now()),
|
||||
from: from,
|
||||
to: to,
|
||||
occurrences: view.occurrences,
|
||||
order: order,
|
||||
),
|
||||
)
|
||||
: DayAgenda(
|
||||
projectId: projectId,
|
||||
trailing: noDate,
|
||||
calendarFor: (days) => calendarOf(
|
||||
kept,
|
||||
Day.of(clock.now()),
|
||||
days: days,
|
||||
occurrences: view.occurrences,
|
||||
order: order,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
|
|
@ -713,6 +714,146 @@ void main() {
|
|||
);
|
||||
expect(find.byTooltip('Mark as done'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('as a month on a wide window', () {
|
||||
/// Another day of today's month, so in its grid.
|
||||
Day otherDay() {
|
||||
final today = Day.of(DateTime.now());
|
||||
return today.plus(1).month == today.month
|
||||
? today.plus(1)
|
||||
: today.plus(-1);
|
||||
}
|
||||
|
||||
/// The Inbox's Calendar on a desktop's window, [titles] today.
|
||||
Future<List<String>> monthOf(
|
||||
WidgetTester tester, [
|
||||
List<String> titles = const ['Dentist'],
|
||||
]) async {
|
||||
await calendarOf(tester);
|
||||
for (final title in titles) {
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: title,
|
||||
projectId: inbox(),
|
||||
date: TaskDate(Day.of(DateTime.now())),
|
||||
),
|
||||
);
|
||||
}
|
||||
tester.view.physicalSize = const Size(1000, 900);
|
||||
await tester.pumpAndSettle();
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
return [
|
||||
for (final title in titles)
|
||||
stored!.singleWhere((t) => t.content?.title == title).id,
|
||||
];
|
||||
}
|
||||
|
||||
testWidgets('the month’s weeks, each day with its tasks; the agenda '
|
||||
'again once the window narrows', (tester) async {
|
||||
final [id] = await monthOf(tester);
|
||||
final today = Day.of(DateTime.now());
|
||||
expect(find.byType(MonthGrid), findsOneWidget);
|
||||
expect(find.byType(WeekStrip), findsNothing);
|
||||
expect(
|
||||
find.byKey(MonthGrid.chipKey(id, today)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(MonthGrid.dayKey(Day(today.year, today.month, 1))),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Following month'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(MonthGrid.chipKey(id, today)), findsNothing);
|
||||
await tester.tap(find.byTooltip('Back to today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(MonthGrid.chipKey(id, today)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MonthGrid), findsNothing);
|
||||
expect(find.byType(WeekStrip), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a task held and dropped on another day moves there, '
|
||||
'which can be undone', (tester) async {
|
||||
final [id] = await monthOf(tester);
|
||||
final today = Day.of(DateTime.now());
|
||||
final target = otherDay();
|
||||
final from = tester.getCenter(
|
||||
find.byKey(MonthGrid.chipKey(id, today)),
|
||||
);
|
||||
final to = tester.getCenter(find.byKey(MonthGrid.dayKey(target)));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(
|
||||
kLongPressTimeout + const Duration(milliseconds: 50),
|
||||
);
|
||||
for (var step = 1; step <= 10; step++) {
|
||||
await gesture.moveTo(Offset.lerp(from, to, step / 10)!);
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Future<Day?> dayOf() async {
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
return stored!.singleWhere((t) => t.id == id).content!.date?.day;
|
||||
}
|
||||
|
||||
expect(await dayOf(), target);
|
||||
expect(find.byKey(MonthGrid.chipKey(id, target)), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await dayOf(), today);
|
||||
});
|
||||
|
||||
testWidgets('a day with more than fits counts the rest, which open '
|
||||
'with the others in a sheet', (tester) async {
|
||||
final titles = [for (var i = 1; i <= 8; i++) 'Task $i'];
|
||||
await monthOf(tester, titles);
|
||||
final today = Day.of(DateTime.now());
|
||||
final more = find.byKey(MonthGrid.moreKey(today));
|
||||
expect(more, findsOneWidget);
|
||||
final shown = titles
|
||||
.where((title) => find.text(title).evaluate().isNotEmpty)
|
||||
.length;
|
||||
expect(shown, lessThan(titles.length));
|
||||
expect(find.text('${titles.length - shown} more'), findsOneWidget);
|
||||
|
||||
await tester.tap(more);
|
||||
await tester.pumpAndSettle();
|
||||
for (final title in titles) {
|
||||
expect(row(title), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a day’s free space tapped adds a task on it', (
|
||||
tester,
|
||||
) async {
|
||||
await monthOf(tester);
|
||||
final target = otherDay();
|
||||
final cell = find.byKey(MonthGrid.dayKey(target));
|
||||
await tester.tapAt(
|
||||
tester.getBottomRight(cell) - const Offset(8, 8),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
final call = stored!.singleWhere((t) => t.content?.title == 'Call');
|
||||
expect(call.content!.date!.day, target);
|
||||
expect(call.content!.projectId, inbox());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
group("Upcoming's Display (D17)", () {
|
||||
|
|
|
|||
|
|
@ -171,4 +171,89 @@ void main() {
|
|||
);
|
||||
expect(shown(calendar), {today: 'a,b'});
|
||||
});
|
||||
|
||||
group('a month as a grid (D17)', () {
|
||||
test('its weeks from the one with its first day to the one with its '
|
||||
'last, starting on the week’s first day', () {
|
||||
// November 2026 starts on a Sunday and ends on a Monday.
|
||||
expect(monthWeeks(const Day(2026, 11, 1), DateTime.monday), [
|
||||
const Day(2026, 10, 26),
|
||||
const Day(2026, 11, 2),
|
||||
const Day(2026, 11, 9),
|
||||
const Day(2026, 11, 16),
|
||||
const Day(2026, 11, 23),
|
||||
const Day(2026, 11, 30),
|
||||
]);
|
||||
expect(monthWeeks(const Day(2026, 11, 20), DateTime.sunday), [
|
||||
const Day(2026, 11, 1),
|
||||
const Day(2026, 11, 8),
|
||||
const Day(2026, 11, 15),
|
||||
const Day(2026, 11, 22),
|
||||
const Day(2026, 11, 29),
|
||||
]);
|
||||
// February 2027 fills four weeks from a Monday.
|
||||
expect(monthWeeks(const Day(2027, 2, 1), DateTime.monday), hasLength(4));
|
||||
});
|
||||
|
||||
test('each day from its first to before its end with its tasks, past '
|
||||
'ones too, done ones where kept, by time', () {
|
||||
final days = monthOf(
|
||||
[
|
||||
task('none'),
|
||||
task('late', day: today.plus(-3)),
|
||||
task('done', day: today.plus(-1), completed: true),
|
||||
task('noon', day: today.plus(2), time: 12 * 60),
|
||||
task('morning', day: today.plus(2), time: 8 * 60),
|
||||
task('after', day: today.plus(30)),
|
||||
],
|
||||
today,
|
||||
from: today.plus(-5),
|
||||
to: today.plus(30),
|
||||
);
|
||||
expect(days.keys.first, today.plus(-5));
|
||||
expect(days, hasLength(35));
|
||||
expect(
|
||||
{
|
||||
for (final MapEntry(:key, :value) in days.entries)
|
||||
if (value.isNotEmpty)
|
||||
key: [for (final entry in value) entry.task.id].join(','),
|
||||
},
|
||||
{
|
||||
today.plus(-3): 'late',
|
||||
today.plus(-1): 'done',
|
||||
today.plus(2): 'morning,noon',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('later times of repeating tasks from today on alone, where asked', () {
|
||||
final weekly = task(
|
||||
'weekly',
|
||||
day: today.plus(-7),
|
||||
repeat: const Repeat(RepeatUnit.week),
|
||||
);
|
||||
expect(
|
||||
monthOf([weekly], today, from: today.plus(-14), to: today.plus(14))
|
||||
.values
|
||||
.expand((entries) => entries)
|
||||
.map((entry) => entry.task.content.date!.day),
|
||||
[today.plus(-7)],
|
||||
);
|
||||
final days = monthOf(
|
||||
[weekly],
|
||||
today,
|
||||
from: today.plus(-14),
|
||||
to: today.plus(14),
|
||||
occurrences: true,
|
||||
);
|
||||
final later = [
|
||||
for (final MapEntry(:key, :value) in days.entries)
|
||||
for (final entry in value)
|
||||
if (entry.occurrence) key,
|
||||
];
|
||||
// Overdue a week, done today it would come a week on, then the
|
||||
// grid ends.
|
||||
expect(later, [today.plus(7)]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue