feat(todo): a project as a table on wide windows (ADR 0033, D36)

Task, date, deadline, priority and labels in columns under a heading that
stays. A phone isn't offered it and shows a project kept as one as a list.

Whats-New: Table: on a wide window a project can show its tasks in columns, through Display.
Whats-New-De: Tabelle: In einem breiten Fenster zeigt ein Projekt seine Aufgaben in Spalten, über Anzeige.

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-06 19:49:15 +01:00
commit 6385d84cff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 441 additions and 10 deletions

File diff suppressed because one or more lines are too long

View file

@ -251,6 +251,10 @@
"groupNoLabel": "Ohne Label",
"layoutTitle": "Layout",
"layoutList": "Liste",
"layoutTable": "Tabelle",
"taskColumnTask": "Aufgabe",
"taskColumnDate": "Datum",
"taskColumnDeadline": "Frist",
"layoutBoard": "Board",
"boardNoSection": "(Kein Abschnitt)",
"layoutCalendar": "Kalender",

View file

@ -737,6 +737,10 @@
"@layoutList": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutTable": "Table",
"taskColumnTask": "Task",
"taskColumnDate": "Date",
"taskColumnDeadline": "Deadline",
"layoutBoard": "Board",
"@layoutBoard": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."

View file

@ -1606,6 +1606,30 @@ abstract class AppLocalizations {
/// **'List'**
String get layoutList;
/// No description provided for @layoutTable.
///
/// In en, this message translates to:
/// **'Table'**
String get layoutTable;
/// No description provided for @taskColumnTask.
///
/// In en, this message translates to:
/// **'Task'**
String get taskColumnTask;
/// No description provided for @taskColumnDate.
///
/// In en, this message translates to:
/// **'Date'**
String get taskColumnDate;
/// No description provided for @taskColumnDeadline.
///
/// In en, this message translates to:
/// **'Deadline'**
String get taskColumnDeadline;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:

View file

@ -995,6 +995,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get layoutList => 'Liste';
@override
String get layoutTable => 'Tabelle';
@override
String get taskColumnTask => 'Aufgabe';
@override
String get taskColumnDate => 'Datum';
@override
String get taskColumnDeadline => 'Frist';
@override
String get layoutBoard => 'Board';

View file

@ -995,6 +995,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get layoutList => 'List';
@override
String get layoutTable => 'Table';
@override
String get taskColumnTask => 'Task';
@override
String get taskColumnDate => 'Date';
@override
String get taskColumnDeadline => 'Deadline';
@override
String get layoutBoard => 'Board';

View file

@ -7,6 +7,9 @@ enum ProjectLayout() {
list,
board,
calendar,
/// In columns, on wide windows alone (D36); a list on others.
table,
}
/// What a project's list groups its tasks by (D17); none: its sections.

View file

@ -89,7 +89,12 @@ DisplayTarget projectDisplay(String projectId) {
return [
LayoutPicker<ProjectLayout>(
selected: view.layout,
segments: projectLayouts(l10n),
segments: projectLayouts(
l10n,
table:
context.windowSize.isAtLeastExpanded ||
view.layout == ProjectLayout.table,
),
onPicked: (layout) => apply(
(todo) => write(todo, state, view.copyWith(layout: layout)),
),
@ -449,13 +454,17 @@ class const _DisplaySheet({required final DisplayTarget target})
/// its name below on one line, and a radio button marking the one in use;
/// the picture, the name or the button picks it.
/// A project's layouts as a [LayoutPicker] offers them: List, Board,
/// Calendar, each with its picture.
List<(ProjectLayout, IconData, String)> projectLayouts(AppLocalizations l10n) =>
[
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
(ProjectLayout.calendar, LucideIcons.calendarDays, l10n.layoutCalendar),
];
/// Calendar, and with [table] Table (D36), which a phone has no room for,
/// each with its picture.
List<(ProjectLayout, IconData, String)> projectLayouts(
AppLocalizations l10n, {
required bool table,
}) => [
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
(ProjectLayout.calendar, LucideIcons.calendarDays, l10n.layoutCalendar),
if (table) (ProjectLayout.table, LucideIcons.table, l10n.layoutTable),
];
/// Layouts side by side, as Todoist's (D22): each a phone's outline with its
/// picture, its name below, a radio button marking the [selected] one.

View file

@ -244,7 +244,12 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
children: [
LayoutPicker<ProjectLayout>(
selected: _layout,
segments: projectLayouts(l10n),
segments: projectLayouts(
l10n,
table:
context.windowSize.isAtLeastExpanded ||
_layout == ProjectLayout.table,
),
onPicked: (layout) => setState(() => _layout = layout),
),
],

View file

@ -0,0 +1,210 @@
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/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';
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_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The width of its own a project's table needs (D36); narrower, it is
/// shown as a list.
const projectTableMinWidth = 720.0;
/// A group of a project's table: its heading, none for the tasks in no
/// section, and its tasks.
typedef TableGroup = ({Widget? heading, List<OpenTask> tasks});
const _dateWidth = 148.0;
const _deadlineWidth = 120.0;
const _priorityWidth = 112.0;
const _labelsWidth = 180.0;
const _rowPadding = EdgeInsets.symmetric(horizontal: 12);
/// A project's tasks as a table (ADR 0033, D36), for wide windows: their
/// task, date, deadline, priority and labels in columns under a heading
/// that stays, a row opening the task's sheet. Its rows aren't dragged: the
/// list is where they are placed by hand.
class const ProjectTable({required final List<TableGroup> groups, super.key})
extends StatelessWidget {
static const headingKey = ValueKey('project-table-heading');
static ValueKey<String> rowKey(String id) =>
ValueKey('project-table-row-$id');
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final style = theme.textTheme.labelLarge;
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
PinnedHeaderSliver(
key: headingKey,
child: ColoredBox(
color: theme.colorScheme.surface,
child: Padding(
padding: _rowPadding + const EdgeInsets.symmetric(vertical: 10),
child: _Columns(
task: Padding(
// Past the check, under the tasks' titles.
padding: const EdgeInsetsDirectional.only(start: 48),
child: Text(l10n.taskColumnTask, style: style),
),
date: Text(l10n.taskColumnDate, style: style),
deadline: Text(l10n.taskColumnDeadline, style: style),
priority: Text(l10n.priorityTitle, style: style),
labels: Text(l10n.labelsTitle, style: style),
),
),
),
),
for (final group in groups)
if (group.heading != null || group.tasks.isNotEmpty)
SliverMainAxisGroup(
slivers: [
if (group.heading case final heading?)
SliverToBoxAdapter(child: heading),
SliverList.builder(
itemCount: group.tasks.length,
itemBuilder: (context, index) =>
_TableRow(task: group.tasks[index]),
),
],
),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
),
],
);
}
}
/// A row's cells at the table's widths, the task's taking what is left.
class const _Columns({
required final Widget task,
required final Widget date,
required final Widget deadline,
required final Widget priority,
required final Widget labels,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(child: task),
SizedBox(width: _dateWidth, child: date),
SizedBox(width: _deadlineWidth, child: deadline),
SizedBox(width: _priorityWidth, child: priority),
SizedBox(width: _labelsWidth, child: labels),
],
);
}
class const _TableRow({required final OpenTask task}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final content = task.content;
final today = Day.of(clock.now());
final small = theme.textTheme.bodySmall;
final muted = theme.colorScheme.onSurfaceVariant;
final subTasks = subTasksOf(state.tasks, task.id);
final labels = [
for (final id in content.labelIds)
for (final label in state.labels)
if (label.id == id) label.content!,
];
return InkWell(
key: ProjectTable.rowKey(task.id),
onTap: () => openTaskSheet(context, task.id),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Padding(
padding: _rowPadding,
child: _Columns(
task: Row(
children: [
TaskCheck(task: task),
Flexible(
child: Text(
content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: content.completed
? TextStyle(
color: muted,
decoration: TextDecoration.lineThrough,
)
: null,
),
),
if (subTasks.isNotEmpty)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: Text(
'${subTasks.where((t) => t.content.completed).length}/'
'${subTasks.length}',
style: small?.copyWith(color: muted),
),
),
],
),
date: switch (content.date) {
final date? => Text(
dateText(context, date, today),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(
color: dateColor(
context,
date.day,
today,
done: content.completed,
),
),
),
null => const SizedBox.shrink(),
},
deadline: switch (content.deadline) {
final deadline? => Text(
dayText(context, deadline, today),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: muted),
),
null => const SizedBox.shrink(),
},
priority: content.priority == Priority.none
? const SizedBox.shrink()
: Text(
priorityText(l10n, content.priority),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(
color: priorityColor(context, content.priority),
),
),
labels: Text(
labels.map((label) => label.name).join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: muted),
),
),
),
),
);
}
}

View file

@ -10,6 +10,7 @@ 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_calendar_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_table.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.dart';
@ -92,6 +93,80 @@ class const ProjectTasks({required final String projectId, super.key})
if (view.layout == ProjectLayout.board) {
return _board(context, state, view, sections, groups, today);
}
if (view.layout == ProjectLayout.table) {
return LayoutBuilder(
builder: (context, constraints) =>
constraints.maxWidth >= projectTableMinWidth
? _table(context, state, view, sections, groups, today)
: _list(context, state, view, sections, groups, today),
);
}
return _list(context, state, view, sections, groups, today);
}
/// The project's tasks as a table (D36): each section's under its
/// heading, or, grouped otherwise, each group's.
Widget _table(
BuildContext context,
TodoState state,
ProjectView view,
List<StoredSection> sections,
List<_Group> groups,
Day today,
) {
final theme = Theme.of(context);
Widget heading(String title, {Widget? trailing}) => ListTile(
title: Text(title, style: theme.textTheme.titleMedium),
trailing: trailing,
);
if (view.grouping == TaskGrouping.none) {
return ProjectTable(
groups: [
(heading: null, tasks: groups.first.tasks),
for (final (index, section) in sections.indexed)
(
heading: heading(
'${section.content!.name} ${groups[index + 1].tasks.length}',
trailing: SectionMenu(projectId: projectId, section: section),
),
tasks: groups[index + 1].tasks,
),
],
);
}
final labels = {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
};
return ProjectTable(
groups: [
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],
))
(
heading: heading(
'${groupTitle(context, group.key, view.grouping, labels, today)}'
' ${group.tasks.length}',
),
tasks: group.tasks,
),
],
);
}
/// The project's tasks as a list: grouped as [view] says, or each
/// section's under its heading, dragged where [view] allows (D16).
Widget _list(
BuildContext context,
TodoState state,
ProjectView view,
List<StoredSection> sections,
List<_Group> groups,
Day today,
) {
if (view.grouping != TaskGrouping.none) {
return _grouped(
context,
@ -103,6 +178,7 @@ class const ProjectTasks({required final String projectId, super.key})
today,
);
}
final loose = groups.first.tasks;
final last = groups.last;
return NewTaskDropArea(
// Below every row: last in the last group.

View file

@ -25,6 +25,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.da
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/project/widget/project_table.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
@ -854,6 +855,71 @@ void main() {
expect(find.byType(DraggableTask), findsNWidgets(3));
});
group('Table (D36)', () {
testWidgets('a wide window offers it and shows columns; the row opens '
'its task', (tester) async {
await inboxOf(tester);
await seed(
tester,
TaskContent(
title: 'Pay rent',
projectId: inbox(),
priority: Priority.p3,
),
);
tester.view.physicalSize = const Size(1400, 900);
await tester.pumpAndSettle();
await display(tester);
await tester.tap(find.text('Table'));
await tester.pumpAndSettle();
await done(tester);
expect(find.byKey(ProjectTable.headingKey), findsOneWidget);
for (final column in ['Task', 'Date', 'Deadline', 'Labels']) {
expect(
find.descendant(
of: find.byKey(ProjectTable.headingKey),
matching: find.text(column),
),
findsOneWidget,
);
}
expect(find.text('Priority 3'), findsOneWidget);
await tester.tap(find.text('Pay rent'));
await tester.pumpAndSettle();
expect(find.text('Pay rent'), findsNWidgets(2));
});
testWidgets('a phone is not offered it, and shows a table kept '
'elsewhere as a list', (tester) async {
await inboxOf(tester);
await display(tester);
expect(find.text('Table'), findsNothing);
await done(tester);
final inboxContent = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
await tester.runAsync(
() => ProjectRepository(projectRows, keys).update(
inbox(),
inboxContent!
.firstWhere((project) => project.id == inbox())
.content!
.copyWith(
view: const ProjectView(layout: ProjectLayout.table),
),
),
);
projectRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.byKey(ProjectTable.headingKey), findsNothing);
expect(find.byType(TaskTile), findsWidgets);
});
});
group('Board', () {
/// The Inbox as a board, with a section "Errands" holding "Stamps".
Future<String> boardOf(WidgetTester tester) async {

View file

@ -69,6 +69,10 @@ void main() {
}),
);
expect(read?.view, const ProjectView(completed: true));
expect(
decodeView(encodeView(const ProjectView(layout: ProjectLayout.table))),
const ProjectView(layout: ProjectLayout.table),
);
});
test('the payload names its schema and the colour by its name', () {

View file

@ -725,6 +725,8 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
**D36. What of Todoist's Pro stays out** (the developer's, 6 Nov 2026). Calendar sync: not yet. Themes: irrelevant to them. Backups: later. Karma: stays out (D8). What's left, decided the same day and built after D35: a table layout, on wide windows alone (columns Task, Date, Deadline, Priority, Labels), falling back to the list on a phone; comments, plain text with links, edited and deleted, no attachments or reactions yet, Markdown later without changing their data; a goal a day and a week with streaks, charts, days off and vacation mode, a quiet card once a day on reaching it, nothing to share; history as the completed tasks alone, no event log.
The table's rows aren't dragged and its columns aren't chosen: the list is where tasks are placed by hand, and the Display's grouping, sorting and filters apply to it as to the list. Below 720 pixels of its own width, a phone or beside the task pane, a project kept as a table shows as a list, so the same project works on both.
## Steps
1. **The frame**: go_router and `NavigationFrame` with the four tabs (empty screens that