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:
parent
b1f2078357
commit
6385d84cff
13 changed files with 441 additions and 10 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue