feat(todo): a project laid out as a board, its columns its sections (ADR 0033, D17)

The Display's new Layout picks List or Board. The board has a column
for the tasks in no section, then one a section with its menu, each
task a card and each column ending with Add task, a last column adding
a section; grouped otherwise, a column a group. Narrower than a medium
width it pages one column at a time with dots below; from it on the
columns stand side by side. Cards drag within and between columns as
rows do (D16), every scrollable they are in scrolling at its edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 18:51:15 +01:00
commit e4fbfb54d1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 714 additions and 111 deletions

View file

@ -202,5 +202,9 @@
"viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.",
"displaySave": "Speichern",
"displayCancel": "Abbrechen",
"groupNoLabel": "Ohne Label"
"groupNoLabel": "Ohne Label",
"layoutTitle": "Layout",
"layoutList": "Liste",
"layoutBoard": "Board",
"boardNoSection": "(Kein Abschnitt)"
}

View file

@ -608,5 +608,21 @@
"groupNoLabel": "No label",
"@groupNoLabel": {
"description": "A project's group of tasks without a label (ADR 0033, D17)."
},
"layoutTitle": "Layout",
"@layoutTitle": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutList": "List",
"@layoutList": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutBoard": "Board",
"@layoutBoard": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"boardNoSection": "(No section)",
"@boardNoSection": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
}
}

View file

@ -1317,6 +1317,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'No label'**
String get groupNoLabel;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Layout'**
String get layoutTitle;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'List'**
String get layoutList;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Board'**
String get layoutBoard;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'(No section)'**
String get boardNoSection;
}
class _AppLocalizationsDelegate

View file

@ -821,4 +821,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get groupNoLabel => 'Ohne Label';
@override
String get layoutTitle => 'Layout';
@override
String get layoutList => 'Liste';
@override
String get layoutBoard => 'Board';
@override
String get boardNoSection => '(Kein Abschnitt)';
}

View file

@ -820,6 +820,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get groupNoLabel => 'No label';
@override
String get layoutTitle => 'Layout';
@override
String get layoutList => 'List';
@override
String get layoutBoard => 'Board';
@override
String get boardNoSection => '(No section)';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -98,6 +98,35 @@ class const _DisplaySheet({required final String projectId})
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(l10n.layoutTitle),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<ProjectLayout>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: ProjectLayout.list,
icon: const Icon(LucideIcons.list),
label: Text(l10n.layoutList),
),
ButtonSegment(
value: ProjectLayout.board,
icon: const Icon(LucideIcons.squareKanban),
label: Text(l10n.layoutBoard),
),
],
// The calendar comes with its own step (D17); until then a
// project laid out so shows as a list.
selected: {
if (view.layout == ProjectLayout.board)
ProjectLayout.board
else
ProjectLayout.list,
},
onSelectionChanged: (picked) =>
_set(context, view.copyWith(layout: picked.single)),
),
),
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted),

View file

@ -0,0 +1,264 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.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_tile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A column of a project's board: its heading, the section it shows (none:
/// the tasks in no section, or a group, D17), and its tasks in their order.
typedef BoardColumn = ({
String title,
StoredSection? section,
String? sectionId,
List<OpenTask> tasks,
});
/// A project's tasks as a board (ADR 0033, D17, as Todoist's): a column a
/// section, the tasks in none first, each task a card; on a phone one
/// column at a time, paged, the next peeking in and dots below saying
/// where; from a medium width on side by side, scrolled across. Where
/// [reorder], cards are dragged within and between columns, the board
/// scrolling at its edges; a column takes a card or the add button dropped
/// below its cards, last in it. Each column ends with Add task; after them,
/// where [addSection], a column adds a section.
class const ProjectBoard({
required final String projectId,
required final List<BoardColumn> columns,
required final bool reorder,
required final bool addSection,
super.key,
}) extends StatefulWidget {
/// The key of the dots below the paged columns.
static const dotsKey = ValueKey('board-dots');
@override
State<ProjectBoard> createState() => _ProjectBoardState();
}
class _ProjectBoardState() extends State<ProjectBoard> {
final _pages = PageController(viewportFraction: 0.88);
@override
void dispose() {
_pages.dispose();
super.dispose();
}
/// Settles on the nearest column once a drag has scrolled the pages
/// partway.
void _snap() {
if (!_pages.hasClients) {
return;
}
if (_pages.page case final page? when (page - page.round()).abs() > 0.01) {
_pages.animateToPage(
page.round(),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final count = widget.columns.length + (widget.addSection ? 1 : 0);
Widget column(int index) => index < widget.columns.length
? _Column(
projectId: widget.projectId,
column: widget.columns[index],
reorder: widget.reorder,
)
: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.listPlus),
label: Text(l10n.sectionAdd),
onPressed: () => addSection(context, widget.projectId),
),
),
);
return LayoutBuilder(
builder: (context, constraints) =>
WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium
? _wide(count, column)
: _paged(context, count, column),
);
}
Widget _wide(int count, Widget Function(int index) column) =>
ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
SizedBox(width: 320, child: column(index)),
);
Widget _paged(
BuildContext context,
int count,
Widget Function(int index) column,
) {
return Column(
children: [
Expanded(
child: Listener(
onPointerUp: (_) => _snap(),
child: PageView.builder(
controller: _pages,
padEnds: false,
itemCount: count,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(left: 12),
child: column(index),
),
),
),
),
ListenableBuilder(
listenable: _pages,
builder: (context, _) {
final current = _pages.hasClients
? (_pages.page ?? 0).round()
: _pages.initialPage;
final scheme = Theme.of(context).colorScheme;
return Padding(
key: ProjectBoard.dotsKey,
padding: EdgeInsets.only(
top: 8,
bottom: 8 + FloatingNavigationBar.coverOf(context),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < count; index++)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == current
? scheme.onSurface
: scheme.onSurface.withValues(alpha: 0.3),
),
),
],
),
);
},
),
],
);
}
}
class const _Column({
required final String projectId,
required final BoardColumn column,
required final bool reorder,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final tasks = column.tasks;
final section = column.section;
// A drop goes last in the column, below the task placed last.
final last = tasks.isEmpty
? null
: tasks.map((t) => t.content.position).reduce((a, b) => a > b ? a : b);
Widget card(int index) {
final tile = Card.outlined(
margin: const EdgeInsets.symmetric(vertical: 4),
clipBehavior: Clip.antiAlias,
child: TaskTile(task: tasks[index]),
);
return reorder
? placedTaskRow(
state: state,
projectId: projectId,
sectionId: column.sectionId,
tasks: tasks,
index: index,
child: tile,
)
: tile;
}
return DragTarget<Object>(
onWillAcceptWithDetails: (details) =>
details.data is NewTaskDrag || (reorder && details.data is TaskDrag),
onAcceptWithDetails: (details) => unawaited(
dropAt(
context,
details.data,
projectId,
DropSpot(sectionId: column.sectionId, after: last),
),
),
builder: (context, candidates, _) => DecoratedBox(
decoration: BoxDecoration(
color: candidates.isEmpty
? Colors.transparent
: theme.colorScheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: const EdgeInsets.only(left: 4),
title: Text(
'${column.title} '
'${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: switch (section) {
final section? => SectionMenu(
projectId: projectId,
section: section,
),
null => null,
},
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 96),
children: [
for (var index = 0; index < tasks.length; index++)
card(index),
TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.taskAddTitle),
onPressed: () => showQuickAdd(
context,
projectId: projectId,
sectionId: column.sectionId,
),
),
],
),
),
],
),
),
);
}
}

View file

@ -7,6 +7,7 @@ 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/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
@ -83,6 +84,9 @@ class const ProjectTasks({required final String projectId, super.key})
for (final section in sections)
(sectionId: section.id, tasks: kept(sectionId: section.id)),
];
if (view.layout == ProjectLayout.board) {
return _board(context, state, view, sections, groups, today);
}
if (view.grouping != TaskGrouping.none) {
return _grouped(
context,
@ -141,6 +145,64 @@ class const ProjectTasks({required final String projectId, super.key})
);
}
/// The project's tasks as a board (D17): a column for the tasks in no
/// section and one a section, or, grouped otherwise, one a group.
Widget _board(
BuildContext context,
TodoState state,
ProjectView view,
List<StoredSection> sections,
List<_Group> groups,
Day today,
) {
final l10n = AppLocalizations.of(context);
if (view.grouping == TaskGrouping.none) {
return ProjectBoard(
projectId: projectId,
reorder: view.canReorder,
addSection: true,
columns: [
(
title: l10n.boardNoSection,
section: null,
sectionId: null,
tasks: groups.first.tasks,
),
for (final (index, section) in sections.indexed)
(
title: section.content!.name,
section: section,
sectionId: section.id,
tasks: groups[index + 1].tasks,
),
],
);
}
final labels = {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
};
return ProjectBoard(
projectId: projectId,
reorder: false,
addSection: false,
columns: [
for (final group in groupTasks(
[for (final group in groups) ...group.tasks]..sort(view.compare),
view.grouping,
today: today,
labelOrder: [for (final label in state.labels) label.id],
))
(
title: groupTitle(context, group.key, view.grouping, labels, today),
section: null,
sectionId: null,
tasks: group.tasks,
),
],
);
}
/// [tasks] grouped by [view]'s grouping (D17), each group under a heading
/// that stays atop the list while its rows scroll beneath it; nothing
/// dragged, the order being the grouping's.
@ -166,17 +228,8 @@ class const ProjectTasks({required final String projectId, super.key})
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
};
String title(TaskGroupKey key) => switch (key) {
OverdueGroup() => l10n.overdue,
DayGroup(:final day?) => dayHeading(context, day, today),
DayGroup(day: null) => switch (view.grouping) {
TaskGrouping.deadline => l10n.rangeNoDeadline,
_ => l10n.rangeNoDate,
},
PriorityGroup(:final priority) => priorityText(l10n, priority),
LabelGroup(:final labelId?) => labels[labelId] ?? '',
LabelGroup(labelId: null) => l10n.groupNoLabel,
};
String title(TaskGroupKey key) =>
groupTitle(context, key, view.grouping, labels, today);
final groups = groupTasks(
tasks,
view.grouping,
@ -224,39 +277,16 @@ class const ProjectTasks({required final String projectId, super.key})
int index,
OpenTask task, {
required bool reorder,
}) {
if (!reorder) {
return TaskTile(task: task);
}
final tasks = group.tasks;
final subTasks = subTasksOf(state.tasks, task.id);
return TaskDropTarget(
key: ValueKey('drop-${task.id}'),
projectId: projectId,
excluded: task.id,
above: DropSpot(
sectionId: group.sectionId,
after: index > 0 ? tasks[index - 1].content.position : null,
before: task.content.position,
),
below: DropSpot(
sectionId: group.sectionId,
after: task.content.position,
before: index + 1 < tasks.length
? tasks[index + 1].content.position
: null,
),
under: DropSpot(
sectionId: group.sectionId,
parentId: task.id,
after: subTasks.lastOrNull?.content.position,
),
child: DraggableTask(
task: task,
child: TaskTile(task: task),
),
);
}
}) => reorder
? placedTaskRow(
state: state,
projectId: projectId,
sectionId: group.sectionId,
tasks: group.tasks,
index: index,
child: TaskTile(task: group.tasks[index]),
)
: TaskTile(task: task);
Widget _section(
BuildContext context,
@ -266,7 +296,6 @@ class const ProjectTasks({required final String projectId, super.key})
_Group group, {
required bool reorder,
}) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = section.content!;
final tasks = group.tasks;
@ -282,52 +311,7 @@ class const ProjectTasks({required final String projectId, super.key})
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: PopupMenuButton<_SectionAction>(
tooltip: l10n.sectionMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) async {
final todo = PersonScope.of<TodoPerson>(context);
switch (action) {
case _SectionAction.add:
await showQuickAdd(
context,
projectId: projectId,
sectionId: section.id,
);
case _SectionAction.edit:
if (await editSection(context, content) case final edited?) {
await todo.sections.update(section.id, edited);
}
case _SectionAction.delete:
if (await confirmDestructiveAction(
context,
title: l10n.sectionDeleteTitle,
message: l10n.sectionDeleteMessage(content.name),
confirmLabel: l10n.sectionDelete,
)) {
await todo.deleteSection(projectId, section.id);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _SectionAction.add,
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
),
trailing: SectionMenu(projectId: projectId, section: section),
);
return SliverMainAxisGroup(
slivers: [
@ -362,6 +346,127 @@ class const ProjectTasks({required final String projectId, super.key})
}
}
/// A section's menu (D8): a task added there, the section renamed or
/// deleted with its tasks, which asks first.
class const SectionMenu({
required final String projectId,
required final StoredSection section,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final content = section.content!;
return PopupMenuButton<_SectionAction>(
tooltip: l10n.sectionMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) async {
final todo = PersonScope.of<TodoPerson>(context);
switch (action) {
case _SectionAction.add:
await showQuickAdd(
context,
projectId: projectId,
sectionId: section.id,
);
case _SectionAction.edit:
if (await editSection(context, content) case final edited?) {
await todo.sections.update(section.id, edited);
}
case _SectionAction.delete:
if (await confirmDestructiveAction(
context,
title: l10n.sectionDeleteTitle,
message: l10n.sectionDeleteMessage(content.name),
confirmLabel: l10n.sectionDelete,
)) {
await todo.deleteSection(projectId, section.id);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _SectionAction.add,
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
);
}
}
/// The [index]th of [tasks], those of [sectionId] in [projectId] in their
/// order, as [child], held to be dragged into another place and taking a
/// drop above it, below it, or under it as its sub-task (D16).
Widget placedTaskRow({
required TodoState state,
required String projectId,
required String? sectionId,
required List<OpenTask> tasks,
required int index,
required Widget child,
}) {
final task = tasks[index];
final subTasks = subTasksOf(state.tasks, task.id);
return TaskDropTarget(
key: ValueKey('drop-${task.id}'),
projectId: projectId,
excluded: task.id,
above: DropSpot(
sectionId: sectionId,
after: index > 0 ? tasks[index - 1].content.position : null,
before: task.content.position,
),
below: DropSpot(
sectionId: sectionId,
after: task.content.position,
before: index + 1 < tasks.length
? tasks[index + 1].content.position
: null,
),
under: DropSpot(
sectionId: sectionId,
parentId: task.id,
after: subTasks.lastOrNull?.content.position,
),
child: DraggableTask(task: task, child: child),
);
}
/// The heading of the group [key] of tasks grouped by [grouping] on
/// [today] (D17), [labels] naming the labels by their IDs.
String groupTitle(
BuildContext context,
TaskGroupKey key,
TaskGrouping grouping,
Map<String, String> labels,
Day today,
) {
final l10n = AppLocalizations.of(context);
return switch (key) {
OverdueGroup() => l10n.overdue,
DayGroup(:final day?) => dayHeading(context, day, today),
DayGroup(day: null) => switch (grouping) {
TaskGrouping.deadline => l10n.rangeNoDeadline,
_ => l10n.rangeNoDate,
},
PriorityGroup(:final priority) => priorityText(l10n, priority),
LabelGroup(:final labelId?) => labels[labelId] ?? '',
LabelGroup(labelId: null) => l10n.groupNoLabel,
};
}
/// A group of a project's list: the tasks of a section, or of none.
typedef _Group = ({String? sectionId, List<OpenTask> tasks});

View file

@ -185,7 +185,8 @@ class _TaskDropTargetState() extends State<TaskDropTarget> {
/// A task's row dragged after a long press (D16), into another place in
/// its project, or a little to the right to become a sub-task, the list
/// scrolling at its edges; not while tasks are being chosen.
/// scrolling at its edges, and a board's columns too at theirs (D17); not
/// while tasks are being chosen.
class const DraggableTask({
required final OpenTask task,
required final Widget child,
@ -197,11 +198,15 @@ class const DraggableTask({
class _DraggableTaskState() extends State<DraggableTask> {
late final _drag = TaskDrag(widget.task.id);
EdgeDraggingAutoScroller? _scroller;
/// One for each scrollable the row is in, innermost first.
var _scrollers = <EdgeDraggingAutoScroller>[];
void _stop() {
_scroller?.stopAutoScroll();
_scroller = null;
for (final scroller in _scrollers) {
scroller.stopAutoScroll();
}
_scrollers = [];
}
@override
@ -223,20 +228,26 @@ class _DraggableTaskState() extends State<DraggableTask> {
data: _drag,
dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: () {
if (Scrollable.maybeOf(context) case final scrollable?) {
_scroller = EdgeDraggingAutoScroller(
scrollable,
velocityScalar: 20,
_scrollers = [
for (
var scrollable = Scrollable.maybeOf(context);
scrollable != null;
scrollable = Scrollable.maybeOf(scrollable.context)
)
EdgeDraggingAutoScroller(scrollable, velocityScalar: 20),
];
},
onDragUpdate: (details) {
for (final scroller in _scrollers) {
scroller.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
);
}
},
onDragUpdate: (details) => _scroller?.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
),
onDragEnd: (_) => _stop(),
onDraggableCanceled: (_, _) => _stop(),
onDragCompleted: _stop,