feat(todo): a table's columns, chosen and moved, each with its menu (ADR 0033, D36)
As Todoist's after trying it: Description and Created join the columns, Display's Columns turns them and a task's details on and off, and a heading filters, sorts, moves or hides its column. Sorting can run either way, and tasks can be filtered by when they were added. Reporting's project filter shows each project's icon. Whats-New: Table: choose and arrange its columns, and filter, sort or hide one from its heading. Whats-New-De: Tabelle: Wähle und ordne die Spalten und filtere, sortiere oder blende eine über ihre Überschrift aus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c3e5e77fa5
commit
977a513379
17 changed files with 1103 additions and 171 deletions
File diff suppressed because one or more lines are too long
|
|
@ -287,6 +287,18 @@
|
|||
"groupNoLabel": "Ohne Label",
|
||||
"layoutTitle": "Layout",
|
||||
"layoutList": "Liste",
|
||||
"taskColumnDescription": "Beschreibung",
|
||||
"taskColumnCreated": "Erstellt",
|
||||
"tableColumns": "Spalten",
|
||||
"tableDetails": "Aufgabendetails",
|
||||
"tableSubTasks": "Unteraufgaben",
|
||||
"tableReminders": "Erinnerungen",
|
||||
"tableFilter": "Filtern",
|
||||
"tableSortUp": "Aufsteigend sortieren",
|
||||
"tableSortDown": "Absteigend sortieren",
|
||||
"tableMoveLeft": "Nach links",
|
||||
"tableMoveRight": "Nach rechts",
|
||||
"tableHide": "Spalte ausblenden",
|
||||
"layoutTable": "Tabelle",
|
||||
"taskColumnTask": "Aufgabe",
|
||||
"taskColumnDate": "Datum",
|
||||
|
|
|
|||
|
|
@ -832,6 +832,18 @@
|
|||
"@layoutList": {
|
||||
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
|
||||
},
|
||||
"taskColumnDescription": "Description",
|
||||
"taskColumnCreated": "Created",
|
||||
"tableColumns": "Columns",
|
||||
"tableDetails": "Task details",
|
||||
"tableSubTasks": "Sub-tasks",
|
||||
"tableReminders": "Reminders",
|
||||
"tableFilter": "Filter",
|
||||
"tableSortUp": "Sort ascending",
|
||||
"tableSortDown": "Sort descending",
|
||||
"tableMoveLeft": "Move left",
|
||||
"tableMoveRight": "Move right",
|
||||
"tableHide": "Hide column",
|
||||
"layoutTable": "Table",
|
||||
"taskColumnTask": "Task",
|
||||
"taskColumnDate": "Date",
|
||||
|
|
|
|||
|
|
@ -1822,6 +1822,78 @@ abstract class AppLocalizations {
|
|||
/// **'List'**
|
||||
String get layoutList;
|
||||
|
||||
/// No description provided for @taskColumnDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Description'**
|
||||
String get taskColumnDescription;
|
||||
|
||||
/// No description provided for @taskColumnCreated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Created'**
|
||||
String get taskColumnCreated;
|
||||
|
||||
/// No description provided for @tableColumns.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Columns'**
|
||||
String get tableColumns;
|
||||
|
||||
/// No description provided for @tableDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Task details'**
|
||||
String get tableDetails;
|
||||
|
||||
/// No description provided for @tableSubTasks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sub-tasks'**
|
||||
String get tableSubTasks;
|
||||
|
||||
/// No description provided for @tableReminders.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reminders'**
|
||||
String get tableReminders;
|
||||
|
||||
/// No description provided for @tableFilter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter'**
|
||||
String get tableFilter;
|
||||
|
||||
/// No description provided for @tableSortUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort ascending'**
|
||||
String get tableSortUp;
|
||||
|
||||
/// No description provided for @tableSortDown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort descending'**
|
||||
String get tableSortDown;
|
||||
|
||||
/// No description provided for @tableMoveLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move left'**
|
||||
String get tableMoveLeft;
|
||||
|
||||
/// No description provided for @tableMoveRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move right'**
|
||||
String get tableMoveRight;
|
||||
|
||||
/// No description provided for @tableHide.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide column'**
|
||||
String get tableHide;
|
||||
|
||||
/// No description provided for @layoutTable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1141,6 +1141,42 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get layoutList => 'Liste';
|
||||
|
||||
@override
|
||||
String get taskColumnDescription => 'Beschreibung';
|
||||
|
||||
@override
|
||||
String get taskColumnCreated => 'Erstellt';
|
||||
|
||||
@override
|
||||
String get tableColumns => 'Spalten';
|
||||
|
||||
@override
|
||||
String get tableDetails => 'Aufgabendetails';
|
||||
|
||||
@override
|
||||
String get tableSubTasks => 'Unteraufgaben';
|
||||
|
||||
@override
|
||||
String get tableReminders => 'Erinnerungen';
|
||||
|
||||
@override
|
||||
String get tableFilter => 'Filtern';
|
||||
|
||||
@override
|
||||
String get tableSortUp => 'Aufsteigend sortieren';
|
||||
|
||||
@override
|
||||
String get tableSortDown => 'Absteigend sortieren';
|
||||
|
||||
@override
|
||||
String get tableMoveLeft => 'Nach links';
|
||||
|
||||
@override
|
||||
String get tableMoveRight => 'Nach rechts';
|
||||
|
||||
@override
|
||||
String get tableHide => 'Spalte ausblenden';
|
||||
|
||||
@override
|
||||
String get layoutTable => 'Tabelle';
|
||||
|
||||
|
|
|
|||
|
|
@ -1141,6 +1141,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get layoutList => 'List';
|
||||
|
||||
@override
|
||||
String get taskColumnDescription => 'Description';
|
||||
|
||||
@override
|
||||
String get taskColumnCreated => 'Created';
|
||||
|
||||
@override
|
||||
String get tableColumns => 'Columns';
|
||||
|
||||
@override
|
||||
String get tableDetails => 'Task details';
|
||||
|
||||
@override
|
||||
String get tableSubTasks => 'Sub-tasks';
|
||||
|
||||
@override
|
||||
String get tableReminders => 'Reminders';
|
||||
|
||||
@override
|
||||
String get tableFilter => 'Filter';
|
||||
|
||||
@override
|
||||
String get tableSortUp => 'Sort ascending';
|
||||
|
||||
@override
|
||||
String get tableSortDown => 'Sort descending';
|
||||
|
||||
@override
|
||||
String get tableMoveLeft => 'Move left';
|
||||
|
||||
@override
|
||||
String get tableMoveRight => 'Move right';
|
||||
|
||||
@override
|
||||
String get tableHide => 'Hide column';
|
||||
|
||||
@override
|
||||
String get layoutTable => 'Table';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
|
|
@ -56,6 +56,12 @@ Map<String, Object?> encodeView(ProjectView view) => {
|
|||
if (view.priorities.isNotEmpty)
|
||||
'priorities': [for (final p in view.priorities) p.name],
|
||||
if (view.labelIds.isNotEmpty) 'labelIds': [...view.labelIds],
|
||||
if (view.created != DayRange.all) 'created': view.created.name,
|
||||
if (view.descending) 'descending': true,
|
||||
if (!listEquals(view.columns, TaskColumn.shownAtFirst))
|
||||
'columns': [for (final column in view.columns) column.name],
|
||||
if (view.hiddenDetails.isNotEmpty)
|
||||
'hiddenDetails': [for (final detail in view.hiddenDetails) detail.name],
|
||||
};
|
||||
|
||||
/// The view [json] holds; a value this version doesn't know, as at first.
|
||||
|
|
@ -66,6 +72,8 @@ ProjectView decodeView(Object? json) {
|
|||
T named<T extends Enum>(List<T> values, String key, T fallback) =>
|
||||
values.asNameMap()[json[key]] ?? fallback;
|
||||
final priorities = Priority.values.asNameMap();
|
||||
final columns = TaskColumn.values.asNameMap();
|
||||
final details = TaskDetail.values.asNameMap();
|
||||
return ProjectView(
|
||||
layout: named(ProjectLayout.values, 'layout', ProjectLayout.list),
|
||||
completed: json['completed'] == true,
|
||||
|
|
@ -82,6 +90,18 @@ ProjectView decodeView(Object? json) {
|
|||
for (final id in json['labelIds'] as List<Object?>? ?? const [])
|
||||
if (id is String) id,
|
||||
},
|
||||
created: named(DayRange.values, 'created', DayRange.all),
|
||||
descending: json['descending'] == true,
|
||||
columns: switch (json['columns']) {
|
||||
final List<Object?> names => [
|
||||
for (final name in names.toSet()) ?columns[name],
|
||||
],
|
||||
_ => TaskColumn.shownAtFirst,
|
||||
},
|
||||
hiddenDetails: {
|
||||
for (final name in json['hiddenDetails'] as List<Object?>? ?? const [])
|
||||
?details[name],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,41 @@ enum ProjectLayout() {
|
|||
table,
|
||||
}
|
||||
|
||||
/// A column of a project's table (D36); the task's stands among them and
|
||||
/// moves as they do, but can't be hidden.
|
||||
enum TaskColumn() {
|
||||
task,
|
||||
description,
|
||||
date,
|
||||
deadline,
|
||||
priority,
|
||||
labels,
|
||||
created;
|
||||
|
||||
/// What a table shows at first.
|
||||
static const shownAtFirst = [task, date, deadline, priority, labels];
|
||||
|
||||
/// What sorting by it means; none: it doesn't sort.
|
||||
TaskSorting? get sorting => switch (this) {
|
||||
task => TaskSorting.name,
|
||||
date => TaskSorting.date,
|
||||
deadline => TaskSorting.deadline,
|
||||
priority => TaskSorting.priority,
|
||||
created => TaskSorting.addedDate,
|
||||
description || labels => null,
|
||||
};
|
||||
|
||||
/// Whether it filters.
|
||||
bool get filters => this != task && this != description;
|
||||
}
|
||||
|
||||
/// What a table's task cell says of its task beside its name (D36).
|
||||
enum TaskDetail() {
|
||||
subTasks,
|
||||
comments,
|
||||
reminders,
|
||||
}
|
||||
|
||||
/// What a project's list groups its tasks by (D17); none: its sections.
|
||||
enum TaskGrouping() {
|
||||
none,
|
||||
|
|
@ -86,6 +121,19 @@ final class const ProjectView({
|
|||
|
||||
/// The labels kept, a task with any of them; none: all.
|
||||
final Set<String> labelIds = const {},
|
||||
|
||||
/// The days it keeps tasks added on (D36).
|
||||
final DayRange created = DayRange.all,
|
||||
|
||||
/// Whether it sorts the other way round; a task without what is sorted by
|
||||
/// stays last.
|
||||
final bool descending = false,
|
||||
|
||||
/// A table's columns, in their order (D36).
|
||||
final List<TaskColumn> columns = TaskColumn.shownAtFirst,
|
||||
|
||||
/// What a table leaves out of its task cell (D36).
|
||||
final Set<TaskDetail> hiddenDetails = const {},
|
||||
}) with Equatable {
|
||||
/// Whether nothing is changed from how a project shows at first.
|
||||
bool get isDefault => this == const ProjectView();
|
||||
|
|
@ -95,13 +143,45 @@ final class const ProjectView({
|
|||
bool get canReorder =>
|
||||
sorting == TaskSorting.manual && grouping == TaskGrouping.none;
|
||||
|
||||
/// Whether it keeps [content] on [today], the week starting on
|
||||
/// [weekStart]. Done tasks are [completed]'s to keep or not.
|
||||
/// [column] shown or not, shown last where it comes back.
|
||||
ProjectView withColumn(TaskColumn column, {required bool shown}) => copyWith(
|
||||
columns: [
|
||||
for (final kept in columns)
|
||||
if (kept != column) kept,
|
||||
if (shown) column,
|
||||
],
|
||||
);
|
||||
|
||||
/// [column] moved [by] places along, where there is room.
|
||||
ProjectView movingColumn(TaskColumn column, int by) {
|
||||
final at = columns.indexOf(column);
|
||||
final to = at + by;
|
||||
if (at < 0 || to < 0 || to >= columns.length) {
|
||||
return this;
|
||||
}
|
||||
return copyWith(
|
||||
columns: [...columns]
|
||||
..removeAt(at)
|
||||
..insert(to, column),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether it keeps [content], the task [id], on [today], the week
|
||||
/// starting on [weekStart]. Done tasks are [completed]'s to keep or not.
|
||||
bool shows(
|
||||
TaskContent content, {
|
||||
required String id,
|
||||
required Day today,
|
||||
required Day weekStart,
|
||||
}) =>
|
||||
created.holds(
|
||||
switch (addedAt(id)) {
|
||||
final at? => Day.of(at.toLocal()),
|
||||
null => null,
|
||||
},
|
||||
today: today,
|
||||
weekStart: weekStart,
|
||||
) &&
|
||||
date.holds(content.date?.day, today: today, weekStart: weekStart) &&
|
||||
deadline.holds(content.deadline, today: today, weekStart: weekStart) &&
|
||||
(priorities.isEmpty || priorities.contains(content.priority)) &&
|
||||
|
|
@ -110,6 +190,7 @@ final class const ProjectView({
|
|||
/// Orders two tasks as it sorts them; a tie, or a task without what is
|
||||
/// sorted by (last), as placed by hand.
|
||||
int compare(OpenTask a, OpenTask b) {
|
||||
final order = descending ? -1 : 1;
|
||||
int nullsLast<T extends Comparable<Object>>(T? x, T? y) => switch ((
|
||||
x,
|
||||
y,
|
||||
|
|
@ -117,22 +198,23 @@ final class const ProjectView({
|
|||
(null, null) => 0,
|
||||
(null, _) => 1,
|
||||
(_, null) => -1,
|
||||
(final x?, final y?) => x.compareTo(y),
|
||||
(final x?, final y?) => order * x.compareTo(y),
|
||||
};
|
||||
final first = switch (sorting) {
|
||||
TaskSorting.manual => 0,
|
||||
TaskSorting.name => a.content.title.toLowerCase().compareTo(
|
||||
b.content.title.toLowerCase(),
|
||||
),
|
||||
TaskSorting.name =>
|
||||
order *
|
||||
a.content.title.toLowerCase().compareTo(
|
||||
b.content.title.toLowerCase(),
|
||||
),
|
||||
TaskSorting.date => nullsLast(_dateKey(a), _dateKey(b)),
|
||||
TaskSorting.deadline => nullsLast(
|
||||
a.content.deadline?.toDate(),
|
||||
b.content.deadline?.toDate(),
|
||||
),
|
||||
TaskSorting.addedDate => nullsLast(addedAt(a.id), addedAt(b.id)),
|
||||
TaskSorting.priority => a.content.priority.index.compareTo(
|
||||
b.content.priority.index,
|
||||
),
|
||||
TaskSorting.priority =>
|
||||
order * a.content.priority.index.compareTo(b.content.priority.index),
|
||||
};
|
||||
return first != 0 ? first : byPosition(a, b);
|
||||
}
|
||||
|
|
@ -154,6 +236,10 @@ final class const ProjectView({
|
|||
DayRange? deadline,
|
||||
Set<Priority>? priorities,
|
||||
Set<String>? labelIds,
|
||||
DayRange? created,
|
||||
bool? descending,
|
||||
List<TaskColumn>? columns,
|
||||
Set<TaskDetail>? hiddenDetails,
|
||||
}) => ProjectView(
|
||||
layout: layout ?? this.layout,
|
||||
completed: completed ?? this.completed,
|
||||
|
|
@ -164,6 +250,10 @@ final class const ProjectView({
|
|||
deadline: deadline ?? this.deadline,
|
||||
priorities: priorities ?? this.priorities,
|
||||
labelIds: labelIds ?? this.labelIds,
|
||||
created: created ?? this.created,
|
||||
descending: descending ?? this.descending,
|
||||
columns: columns ?? this.columns,
|
||||
hiddenDetails: hiddenDetails ?? this.hiddenDetails,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -177,6 +267,10 @@ final class const ProjectView({
|
|||
deadline,
|
||||
priorities,
|
||||
labelIds,
|
||||
created,
|
||||
descending,
|
||||
columns,
|
||||
hiddenDetails,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,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/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_table.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
|
@ -99,6 +100,23 @@ DisplayTarget projectDisplay(String projectId) {
|
|||
(todo) => write(todo, state, view.copyWith(layout: layout)),
|
||||
),
|
||||
),
|
||||
if (view.layout == ProjectLayout.table)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.columns3),
|
||||
title: Text(l10n.tableColumns),
|
||||
trailing: Badge(
|
||||
smallSize: 8,
|
||||
isLabelVisible: !tableIsDefault(view),
|
||||
child: const Icon(LucideIcons.chevronRight),
|
||||
),
|
||||
onTap: () => showTableColumns(
|
||||
context,
|
||||
viewOf: viewOf,
|
||||
write: (changed) => apply(
|
||||
(todo) => write(todo, context.read<TodoCubit>().state, changed),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
|
|
@ -146,7 +164,7 @@ final upcomingDisplay = DisplayTarget(
|
|||
title: Text(l10n.displayColoring),
|
||||
subtitle: Text(coloringText(l10n, upcoming.coloring)),
|
||||
onTap: () async {
|
||||
if (await _pickOne(
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.displayColoring,
|
||||
TaskColoring.values,
|
||||
|
|
@ -324,7 +342,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
l10n.displayGrouping,
|
||||
groupingText(l10n, view.grouping),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.displayGrouping,
|
||||
TaskGrouping.values,
|
||||
|
|
@ -341,7 +359,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
l10n.displaySorting,
|
||||
sortingName(view.sorting),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.displaySorting,
|
||||
target.sortings,
|
||||
|
|
@ -364,7 +382,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
l10n.dateTitle,
|
||||
rangeText(l10n, view.date),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.dateTitle,
|
||||
DayRange.values,
|
||||
|
|
@ -381,7 +399,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
l10n.displayDeadline,
|
||||
rangeText(l10n, view.deadline, deadline: true),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.displayDeadline,
|
||||
DayRange.values,
|
||||
|
|
@ -393,6 +411,24 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
}
|
||||
},
|
||||
),
|
||||
if (target.dateFilter)
|
||||
choice(
|
||||
LucideIcons.clock,
|
||||
l10n.taskColumnCreated,
|
||||
rangeText(l10n, view.created),
|
||||
() async {
|
||||
if (await pickOne(
|
||||
context,
|
||||
l10n.taskColumnCreated,
|
||||
DayRange.values,
|
||||
view.created,
|
||||
(range) => rangeText(l10n, range),
|
||||
)
|
||||
case final range?) {
|
||||
await set(view.copyWith(created: range));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.flag,
|
||||
l10n.priorityTitle,
|
||||
|
|
@ -404,7 +440,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
priorityText(l10n, priority),
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await _pickPriorities(context, view.priorities)
|
||||
if (await pickPriorities(context, view.priorities)
|
||||
case final picked?) {
|
||||
await set(view.copyWith(priorities: picked));
|
||||
}
|
||||
|
|
@ -559,7 +595,7 @@ class const RoundedGroup({required final List<Widget> children, super.key})
|
|||
}
|
||||
|
||||
/// Asks for one of [values], [current] ticked.
|
||||
Future<T?> _pickOne<T>(
|
||||
Future<T?> pickOne<T>(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<T> values,
|
||||
|
|
@ -581,7 +617,7 @@ Future<T?> _pickOne<T>(
|
|||
);
|
||||
|
||||
/// Asks for the priorities kept, [current] ticked; none ticked keeps all.
|
||||
Future<Set<Priority>?> _pickPriorities(
|
||||
Future<Set<Priority>?> pickPriorities(
|
||||
BuildContext context,
|
||||
Set<Priority> current,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,12 @@ class const ProjectCalendarView({
|
|||
for (final task in state.tasks)
|
||||
if (task.content.projectId == projectId &&
|
||||
(view.completed || !task.content.completed) &&
|
||||
view.shows(task.content, today: today, weekStart: weekStart))
|
||||
view.shows(
|
||||
task.content,
|
||||
id: task.id,
|
||||
today: today,
|
||||
weekStart: weekStart,
|
||||
))
|
||||
task,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,127 +1,393 @@
|
|||
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/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/display_sheet.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_pickers.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:lucide_icons_flutter/lucide_icons.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.
|
||||
/// The width of its own a project's table needs to show at all (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 = 130.0;
|
||||
const _deadlineWidth = 110.0;
|
||||
const _priorityWidth = 100.0;
|
||||
const _labelsWidth = 140.0;
|
||||
const _rowPadding = 12.0;
|
||||
const _taskMinWidth = 220.0;
|
||||
const _cellEnd = EdgeInsetsDirectional.only(end: 8);
|
||||
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 {
|
||||
/// How wide [column] is; the task's takes what is left, at least
|
||||
/// [_taskMinWidth].
|
||||
double _widthOf(TaskColumn column) => switch (column) {
|
||||
TaskColumn.task => _taskMinWidth,
|
||||
TaskColumn.description => 240,
|
||||
TaskColumn.date => 130,
|
||||
TaskColumn.deadline => 110,
|
||||
TaskColumn.priority => 100,
|
||||
TaskColumn.labels => 140,
|
||||
TaskColumn.created => 90,
|
||||
};
|
||||
|
||||
/// A column's name.
|
||||
String columnName(AppLocalizations l10n, TaskColumn column) => switch (column) {
|
||||
TaskColumn.task => l10n.taskColumnTask,
|
||||
TaskColumn.description => l10n.taskColumnDescription,
|
||||
TaskColumn.date => l10n.taskColumnDate,
|
||||
TaskColumn.deadline => l10n.taskColumnDeadline,
|
||||
TaskColumn.priority => l10n.priorityTitle,
|
||||
TaskColumn.labels => l10n.labelsTitle,
|
||||
TaskColumn.created => l10n.taskColumnCreated,
|
||||
};
|
||||
|
||||
/// A column's icon.
|
||||
IconData columnIcon(TaskColumn column) => switch (column) {
|
||||
TaskColumn.task => LucideIcons.circle,
|
||||
TaskColumn.description => LucideIcons.alignLeft,
|
||||
TaskColumn.date => LucideIcons.calendar,
|
||||
TaskColumn.deadline => LucideIcons.target,
|
||||
TaskColumn.priority => LucideIcons.flag,
|
||||
TaskColumn.labels => LucideIcons.tag,
|
||||
TaskColumn.created => LucideIcons.clock,
|
||||
};
|
||||
|
||||
/// A project's tasks as a table (ADR 0033, D36, as Todoist's), for wide
|
||||
/// windows: the columns [view] keeps under a heading that stays, a heading
|
||||
/// opening what can be done with its column, a row opening the task's
|
||||
/// sheet. Wider than the room it has, it scrolls sideways. Its rows aren't
|
||||
/// dragged: the list is where they are placed by hand.
|
||||
class const ProjectTable({
|
||||
required final List<TableGroup> groups,
|
||||
required final ProjectView view,
|
||||
required final ValueChanged<ProjectView> onView,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
static const headingKey = ValueKey('project-table-heading');
|
||||
|
||||
static ValueKey<String> rowKey(String id) =>
|
||||
ValueKey('project-table-row-$id');
|
||||
|
||||
static ValueKey<TaskColumn> columnKey(TaskColumn column) => ValueKey(column);
|
||||
|
||||
@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: _heading(l10n.taskColumnTask, style),
|
||||
final need =
|
||||
view.columns
|
||||
.map(_widthOf)
|
||||
.fold<double>(0, (sum, width) => sum + width) +
|
||||
2 * _rowPadding;
|
||||
Widget table(double width) => SizedBox(
|
||||
width: width,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
PinnedHeaderSliver(
|
||||
key: headingKey,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _rowPadding,
|
||||
),
|
||||
child: _Cells(
|
||||
columns: view.columns,
|
||||
cell: (column) => _Heading(
|
||||
column: column,
|
||||
view: view,
|
||||
onView: onView,
|
||||
),
|
||||
),
|
||||
date: _heading(l10n.taskColumnDate, style),
|
||||
deadline: _heading(l10n.taskColumnDeadline, style),
|
||||
priority: _heading(l10n.priorityTitle, style),
|
||||
labels: _heading(l10n.labelsTitle, 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]),
|
||||
),
|
||||
],
|
||||
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],
|
||||
view: view,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth >= need
|
||||
? table(constraints.maxWidth)
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: table(need),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _heading(String text, TextStyle? style) =>
|
||||
Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: style);
|
||||
|
||||
/// 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,
|
||||
/// A row's cells in [columns]' order and widths, the task's taking what is
|
||||
/// left.
|
||||
class const _Cells({
|
||||
required final List<TaskColumn> columns,
|
||||
required final Widget Function(TaskColumn column) cell,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(padding: _cellEnd, child: task),
|
||||
),
|
||||
SizedBox(
|
||||
width: _dateWidth,
|
||||
child: Padding(padding: _cellEnd, child: date),
|
||||
),
|
||||
SizedBox(
|
||||
width: _deadlineWidth,
|
||||
child: Padding(padding: _cellEnd, child: deadline),
|
||||
),
|
||||
SizedBox(
|
||||
width: _priorityWidth,
|
||||
child: Padding(padding: _cellEnd, child: priority),
|
||||
),
|
||||
SizedBox(width: _labelsWidth, child: labels),
|
||||
for (final column in columns)
|
||||
if (column == TaskColumn.task)
|
||||
Expanded(child: cell(column))
|
||||
else
|
||||
SizedBox(width: _widthOf(column), child: cell(column)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class const _TableRow({required final OpenTask task}) extends StatelessWidget {
|
||||
/// A column's heading (as Todoist's): hovered it shows it can be opened,
|
||||
/// and a tap or click opens what can be done with the column.
|
||||
class const _Heading({
|
||||
required final TaskColumn column,
|
||||
required final ProjectView view,
|
||||
required final ValueChanged<ProjectView> onView,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_Heading> createState() => _HeadingState();
|
||||
}
|
||||
|
||||
class _HeadingState() extends State<_Heading> {
|
||||
var _hovered = false;
|
||||
|
||||
Future<void> _filter(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final view = widget.view;
|
||||
Future<DayRange?> range(DayRange current, {bool deadline = false}) =>
|
||||
pickOne(
|
||||
context,
|
||||
columnName(l10n, widget.column),
|
||||
DayRange.values,
|
||||
current,
|
||||
(range) => rangeText(l10n, range, deadline: deadline),
|
||||
);
|
||||
switch (widget.column) {
|
||||
case TaskColumn.date:
|
||||
if (await range(view.date) case final picked?) {
|
||||
widget.onView(view.copyWith(date: picked));
|
||||
}
|
||||
case TaskColumn.deadline:
|
||||
if (await range(view.deadline, deadline: true) case final picked?) {
|
||||
widget.onView(view.copyWith(deadline: picked));
|
||||
}
|
||||
case TaskColumn.created:
|
||||
if (await range(view.created) case final picked?) {
|
||||
widget.onView(view.copyWith(created: picked));
|
||||
}
|
||||
case TaskColumn.priority:
|
||||
if (await pickPriorities(context, view.priorities) case final picked?) {
|
||||
widget.onView(view.copyWith(priorities: picked));
|
||||
}
|
||||
case TaskColumn.labels:
|
||||
if (await pickLabels(context, [...view.labelIds]) case final picked?) {
|
||||
widget.onView(view.copyWith(labelIds: {...picked}));
|
||||
}
|
||||
case TaskColumn.task || TaskColumn.description:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _filtered => switch (widget.column) {
|
||||
TaskColumn.date => widget.view.date != DayRange.all,
|
||||
TaskColumn.deadline => widget.view.deadline != DayRange.all,
|
||||
TaskColumn.created => widget.view.created != DayRange.all,
|
||||
TaskColumn.priority => widget.view.priorities.isNotEmpty,
|
||||
TaskColumn.labels => widget.view.labelIds.isNotEmpty,
|
||||
TaskColumn.task || TaskColumn.description => false,
|
||||
};
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final column = widget.column;
|
||||
final view = widget.view;
|
||||
final at = view.columns.indexOf(column);
|
||||
final box = context.findRenderObject()! as RenderBox;
|
||||
final overlay =
|
||||
Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
|
||||
final origin = box.localToGlobal(
|
||||
box.size.bottomLeft(Offset.zero),
|
||||
ancestor: overlay,
|
||||
);
|
||||
final picked = await showMenu<_ColumnAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
origin & const Size(1, 1),
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: [
|
||||
if (column.filters) ...[
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.filter,
|
||||
child: MenuEntry(LucideIcons.listFilter, l10n.tableFilter),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
],
|
||||
if (column.sorting != null) ...[
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.sortUp,
|
||||
child: MenuEntry(LucideIcons.arrowUp, l10n.tableSortUp),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.sortDown,
|
||||
child: MenuEntry(LucideIcons.arrowDown, l10n.tableSortDown),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.left,
|
||||
enabled: at > 0,
|
||||
child: MenuEntry(LucideIcons.arrowLeft, l10n.tableMoveLeft),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.right,
|
||||
enabled: at < view.columns.length - 1,
|
||||
child: MenuEntry(LucideIcons.arrowRight, l10n.tableMoveRight),
|
||||
),
|
||||
if (column != TaskColumn.task) ...[
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: _ColumnAction.hide,
|
||||
child: MenuEntry(LucideIcons.eyeOff, l10n.tableHide),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
switch (picked) {
|
||||
case _ColumnAction.filter:
|
||||
await _filter(context);
|
||||
case _ColumnAction.sortUp:
|
||||
widget.onView(
|
||||
view.copyWith(sorting: column.sorting, descending: false),
|
||||
);
|
||||
case _ColumnAction.sortDown:
|
||||
widget.onView(view.copyWith(sorting: column.sorting, descending: true));
|
||||
case _ColumnAction.left:
|
||||
widget.onView(view.movingColumn(column, -1));
|
||||
case _ColumnAction.right:
|
||||
widget.onView(view.movingColumn(column, 1));
|
||||
case _ColumnAction.hide:
|
||||
widget.onView(view.withColumn(column, shown: false));
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final view = widget.view;
|
||||
final sorted =
|
||||
widget.column.sorting != null && widget.column.sorting == view.sorting;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: InkWell(
|
||||
key: ProjectTable.columnKey(widget.column),
|
||||
onTap: () => unawaited(_open(context)),
|
||||
child: Container(
|
||||
height: 40,
|
||||
color: _hovered ? theme.colorScheme.surfaceContainerHigh : null,
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, end: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
columnName(l10n, widget.column),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
if (sorted)
|
||||
Icon(
|
||||
view.descending ? LucideIcons.arrowDown : LucideIcons.arrowUp,
|
||||
size: 14,
|
||||
),
|
||||
if (_filtered) const Icon(LucideIcons.listFilter, size: 14),
|
||||
if (_hovered) ...[
|
||||
const Spacer(),
|
||||
const Icon(LucideIcons.ellipsis, size: 18),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ColumnAction() {
|
||||
filter,
|
||||
sortUp,
|
||||
sortDown,
|
||||
left,
|
||||
right,
|
||||
hide,
|
||||
}
|
||||
|
||||
class const _TableRow({
|
||||
required final OpenTask task,
|
||||
required final ProjectView view,
|
||||
}) extends StatelessWidget {
|
||||
Widget _detail(BuildContext context, IconData icon, String text) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: muted),
|
||||
const SizedBox(width: 2),
|
||||
Text(text, style: theme.textTheme.bodySmall?.copyWith(color: muted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _text(String text, TextStyle? style) => Padding(
|
||||
padding: _cellEnd + const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -132,11 +398,91 @@ class const _TableRow({required final OpenTask task}) extends StatelessWidget {
|
|||
final small = theme.textTheme.bodySmall;
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
final comments = state.commentsOn(task.id).length;
|
||||
final reminders = content.reminders.length + content.reminderTimes.length;
|
||||
final hidden = view.hiddenDetails;
|
||||
final labels = [
|
||||
for (final id in content.labelIds)
|
||||
for (final label in state.labels)
|
||||
if (label.id == id) label.content!,
|
||||
if (label.id == id) label.content!.name,
|
||||
];
|
||||
Widget cell(TaskColumn column) => switch (column) {
|
||||
TaskColumn.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 && !hidden.contains(TaskDetail.subTasks))
|
||||
_detail(
|
||||
context,
|
||||
LucideIcons.listTree,
|
||||
'${subTasks.where((t) => t.content.completed).length}/'
|
||||
'${subTasks.length}',
|
||||
),
|
||||
if (comments > 0 && !hidden.contains(TaskDetail.comments))
|
||||
_detail(context, LucideIcons.messageSquare, '$comments'),
|
||||
if (reminders > 0 && !hidden.contains(TaskDetail.reminders))
|
||||
_detail(context, LucideIcons.alarmClock, '$reminders'),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
TaskColumn.description => _text(
|
||||
content.description,
|
||||
small?.copyWith(color: muted),
|
||||
),
|
||||
TaskColumn.date => switch (content.date) {
|
||||
final date? => _text(
|
||||
dateText(context, date, today),
|
||||
small?.copyWith(
|
||||
color: dateColor(
|
||||
context,
|
||||
date.day,
|
||||
today,
|
||||
done: content.completed,
|
||||
),
|
||||
),
|
||||
),
|
||||
null => const SizedBox.shrink(),
|
||||
},
|
||||
TaskColumn.deadline => switch (content.deadline) {
|
||||
final deadline? => _text(
|
||||
dayText(context, deadline, today),
|
||||
small?.copyWith(color: muted),
|
||||
),
|
||||
null => const SizedBox.shrink(),
|
||||
},
|
||||
TaskColumn.priority =>
|
||||
content.priority == Priority.none
|
||||
? const SizedBox.shrink()
|
||||
: _text(
|
||||
priorityText(l10n, content.priority),
|
||||
small?.copyWith(
|
||||
color: priorityColor(context, content.priority),
|
||||
),
|
||||
),
|
||||
TaskColumn.labels => _text(
|
||||
labels.join(', '),
|
||||
small?.copyWith(color: muted),
|
||||
),
|
||||
TaskColumn.created => switch (addedAt(task.id)) {
|
||||
final at? => _text(
|
||||
dayText(context, Day.of(at.toLocal()), today),
|
||||
small?.copyWith(color: muted),
|
||||
),
|
||||
null => const SizedBox.shrink(),
|
||||
},
|
||||
};
|
||||
return InkWell(
|
||||
key: ProjectTable.rowKey(task.id),
|
||||
onTap: () => openTaskSheet(context, task.id),
|
||||
|
|
@ -147,79 +493,114 @@ class const _TableRow({required final OpenTask task}) extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: _rowPadding),
|
||||
child: _Cells(columns: view.columns, cell: cell),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a table shows (D36, as Todoist's Columns): its columns, each on or
|
||||
/// off, and what its task cell says beside a task's name.
|
||||
Future<void> showTableColumns(
|
||||
BuildContext context, {
|
||||
required ProjectView Function(TodoState state) viewOf,
|
||||
required Future<void> Function(ProjectView view) write,
|
||||
}) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final view = viewOf(context.watch<TodoCubit>().state);
|
||||
Widget heading(String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
return SheetScaffold(
|
||||
title: l10n.tableColumns,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
RoundedGroup(
|
||||
children: [
|
||||
for (final column in TaskColumn.values)
|
||||
if (column != TaskColumn.task)
|
||||
SwitchListTile(
|
||||
secondary: Icon(columnIcon(column)),
|
||||
title: Text(columnName(l10n, column)),
|
||||
value: view.columns.contains(column),
|
||||
onChanged: (on) =>
|
||||
write(view.withColumn(column, shown: on)),
|
||||
),
|
||||
],
|
||||
),
|
||||
heading(l10n.tableDetails),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
for (final (detail, icon, name) in [
|
||||
(
|
||||
TaskDetail.subTasks,
|
||||
LucideIcons.listTree,
|
||||
l10n.tableSubTasks,
|
||||
),
|
||||
(
|
||||
TaskDetail.comments,
|
||||
LucideIcons.messageSquare,
|
||||
l10n.commentsTitle,
|
||||
),
|
||||
(
|
||||
TaskDetail.reminders,
|
||||
LucideIcons.alarmClock,
|
||||
l10n.tableReminders,
|
||||
),
|
||||
])
|
||||
SwitchListTile(
|
||||
secondary: Icon(icon),
|
||||
title: Text(name),
|
||||
value: !view.hiddenDetails.contains(detail),
|
||||
onChanged: (on) => write(
|
||||
view.copyWith(
|
||||
hiddenDetails: on
|
||||
? ({...view.hiddenDetails}..remove(detail))
|
||||
: {...view.hiddenDetails, detail},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: tableIsDefault(view)
|
||||
? null
|
||||
: () => write(
|
||||
view.copyWith(
|
||||
columns: TaskColumn.shownAtFirst,
|
||||
hiddenDetails: const {},
|
||||
),
|
||||
),
|
||||
child: Text(l10n.displayReset),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.displayDone),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Whether a table shows what it does at first.
|
||||
bool tableIsDefault(ProjectView view) =>
|
||||
view.hiddenDetails.isEmpty &&
|
||||
view.columns.length == TaskColumn.shownAtFirst.length &&
|
||||
[
|
||||
for (final (index, column) in view.columns.indexed)
|
||||
column == TaskColumn.shownAtFirst[index],
|
||||
].every((same) => same);
|
||||
|
|
|
|||
|
|
@ -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/display_sheet.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';
|
||||
|
|
@ -68,7 +69,13 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
sectionId: sectionId,
|
||||
completed: view.completed,
|
||||
))
|
||||
if (view.shows(task.content, today: today, weekStart: weekStart)) task,
|
||||
if (view.shows(
|
||||
task.content,
|
||||
id: task.id,
|
||||
today: today,
|
||||
weekStart: weekStart,
|
||||
))
|
||||
task,
|
||||
]..sort(view.compare);
|
||||
final loose = kept();
|
||||
final sections = state.sectionsOf(projectId);
|
||||
|
|
@ -115,12 +122,21 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
Day today,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
void onView(ProjectView changed) => unawaited(
|
||||
projectDisplay(projectId).write(
|
||||
PersonScope.of<TodoPerson>(context),
|
||||
state,
|
||||
changed,
|
||||
),
|
||||
);
|
||||
Widget heading(String title, {Widget? trailing}) => ListTile(
|
||||
title: Text(title, style: theme.textTheme.titleMedium),
|
||||
trailing: trailing,
|
||||
);
|
||||
if (view.grouping == TaskGrouping.none) {
|
||||
return ProjectTable(
|
||||
view: view,
|
||||
onView: onView,
|
||||
groups: [
|
||||
(heading: null, tasks: groups.first.tasks),
|
||||
for (final (index, section) in sections.indexed)
|
||||
|
|
@ -139,6 +155,8 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
return ProjectTable(
|
||||
view: view,
|
||||
onView: onView,
|
||||
groups: [
|
||||
for (final group in groupTasks(
|
||||
[for (final group in groups) ...group.tasks]..sort(view.compare),
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ class _ReportingScreenState() extends State<ReportingScreen> {
|
|||
children: [
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: _projectId,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: l10n.historyProject),
|
||||
items: [
|
||||
DropdownMenuItem(child: Text(l10n.historyAllProjects)),
|
||||
|
|
@ -171,8 +172,27 @@ class _ReportingScreenState() extends State<ReportingScreen> {
|
|||
if (project.content case final content?)
|
||||
DropdownMenuItem(
|
||||
value: project.id,
|
||||
child: Text(
|
||||
project.inbox ? l10n.shellTabInbox : content.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
|
||||
size: 18,
|
||||
color: switch (content.color) {
|
||||
final color? when !project.inbox => paletteText(
|
||||
context,
|
||||
color,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
project.inbox ? l10n.shellTabInbox : content.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,7 +60,12 @@ class _UpcomingTabState()
|
|||
final kept = [
|
||||
for (final task in state.tasks)
|
||||
if ((view.completed || !task.content.completed) &&
|
||||
view.shows(task.content, today: today, weekStart: weekStart))
|
||||
view.shows(
|
||||
task.content,
|
||||
id: task.id,
|
||||
today: today,
|
||||
weekStart: weekStart,
|
||||
))
|
||||
task,
|
||||
];
|
||||
ProjectCalendar calendarFor(int days) => calendarOf(
|
||||
|
|
|
|||
|
|
@ -1085,6 +1085,121 @@ void main() {
|
|||
expect(find.text('Pay rent'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
Future<void> asTable(WidgetTester tester, ProjectView view) async {
|
||||
final all = await tester.runAsync(
|
||||
() => ProjectRepository(projectRows, keys).all(),
|
||||
);
|
||||
await tester.runAsync(
|
||||
() => ProjectRepository(projectRows, keys).update(
|
||||
inbox(),
|
||||
all!
|
||||
.firstWhere((project) => project.id == inbox())
|
||||
.content!
|
||||
.copyWith(view: view),
|
||||
),
|
||||
);
|
||||
projectRows.bringRemoteChanges();
|
||||
tester.view.physicalSize = const Size(1400, 900);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
ProjectView? kept(List<StoredProject> projects) => projects
|
||||
.firstWhere((project) => project.id == inbox())
|
||||
.content
|
||||
?.view;
|
||||
|
||||
testWidgets("a heading offers what its column can do: Task can't be "
|
||||
'hidden or filtered, Labels not sorted, the ends not moved '
|
||||
'further', (tester) async {
|
||||
await inboxOf(tester);
|
||||
await asTable(
|
||||
tester,
|
||||
const ProjectView(layout: ProjectLayout.table),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(ProjectTable.columnKey(TaskColumn.task)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sort ascending'), findsOneWidget);
|
||||
expect(find.text('Hide column'), findsNothing);
|
||||
expect(find.text('Filter'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<PopupMenuItem<Object?>>(
|
||||
find.ancestor(
|
||||
of: find.text('Move left'),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is PopupMenuItem,
|
||||
),
|
||||
),
|
||||
)
|
||||
.enabled,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tapAt(const Offset(700, 800));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(ProjectTable.columnKey(TaskColumn.labels)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sort ascending'), findsNothing);
|
||||
expect(find.text('Filter'), findsOneWidget);
|
||||
expect(find.text('Hide column'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hiding a column and sorting from a heading are kept', (
|
||||
tester,
|
||||
) async {
|
||||
await inboxOf(tester);
|
||||
await asTable(
|
||||
tester,
|
||||
const ProjectView(layout: ProjectLayout.table),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(ProjectTable.columnKey(TaskColumn.date)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Hide column'));
|
||||
await tester.pumpAndSettle();
|
||||
projectRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(ProjectTable.columnKey(TaskColumn.date)),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(ProjectTable.columnKey(TaskColumn.task)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sort descending'));
|
||||
await tester.pumpAndSettle();
|
||||
final projects = await tester.runAsync(
|
||||
() => ProjectRepository(projectRows, keys).all(),
|
||||
);
|
||||
expect(kept(projects!)?.sorting, TaskSorting.name);
|
||||
expect(kept(projects)?.descending, isTrue);
|
||||
expect(kept(projects)?.columns, isNot(contains(TaskColumn.date)));
|
||||
});
|
||||
|
||||
testWidgets('more columns than fit scroll sideways, nothing '
|
||||
'overflowing', (tester) async {
|
||||
await inboxOf(tester);
|
||||
await asTable(
|
||||
tester,
|
||||
const ProjectView(
|
||||
layout: ProjectLayout.table,
|
||||
columns: TaskColumn.values,
|
||||
),
|
||||
);
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byKey(ProjectTable.headingKey), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(ProjectTable.columnKey(TaskColumn.created)),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a phone is not offered it, and shows a table kept '
|
||||
'elsewhere as a list', (tester) async {
|
||||
await inboxOf(tester);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,21 @@ void main() {
|
|||
decodeView(encodeView(const ProjectView(layout: ProjectLayout.table))),
|
||||
const ProjectView(layout: ProjectLayout.table),
|
||||
);
|
||||
const table = ProjectView(
|
||||
layout: ProjectLayout.table,
|
||||
created: DayRange.thisWeek,
|
||||
descending: true,
|
||||
columns: [TaskColumn.created, TaskColumn.task, TaskColumn.description],
|
||||
hiddenDetails: {TaskDetail.comments},
|
||||
);
|
||||
expect(decodeView(encodeView(table)), table);
|
||||
expect(
|
||||
decodeView({
|
||||
'columns': ['task', 'mood', 'task'],
|
||||
}).columns,
|
||||
[TaskColumn.task],
|
||||
reason: 'one unknown, one twice',
|
||||
);
|
||||
});
|
||||
|
||||
test('the payload names its schema and the colour by its name', () {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ void main() {
|
|||
|
||||
List<String> filtered(List<OpenTask> tasks, ProjectView view) => ids([
|
||||
for (final t in tasks)
|
||||
if (view.shows(t.content, today: today, weekStart: weekStart)) t,
|
||||
if (view.shows(t.content, id: t.id, today: today, weekStart: weekStart))
|
||||
t,
|
||||
]);
|
||||
|
||||
test('the default view filters nothing, sorts by hand, groups by '
|
||||
|
|
@ -230,4 +231,58 @@ void main() {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
group("a table's columns (D36)", () {
|
||||
test('move along, stopping at the ends', () {
|
||||
const view = ProjectView();
|
||||
|
||||
expect(
|
||||
view.movingColumn(TaskColumn.date, 1).columns.take(3),
|
||||
[TaskColumn.task, TaskColumn.deadline, TaskColumn.date],
|
||||
);
|
||||
expect(view.movingColumn(TaskColumn.task, -1), view);
|
||||
expect(view.movingColumn(TaskColumn.labels, 1), view);
|
||||
expect(view.movingColumn(TaskColumn.created, 1), view);
|
||||
});
|
||||
|
||||
test('hide, and come back last', () {
|
||||
final hidden = const ProjectView().withColumn(
|
||||
TaskColumn.date,
|
||||
shown: false,
|
||||
);
|
||||
|
||||
expect(hidden.columns, isNot(contains(TaskColumn.date)));
|
||||
expect(
|
||||
hidden.withColumn(TaskColumn.date, shown: true).columns.last,
|
||||
TaskColumn.date,
|
||||
);
|
||||
});
|
||||
|
||||
test('sort or filter only where they say so', () {
|
||||
expect(TaskColumn.labels.sorting, isNull);
|
||||
expect(TaskColumn.description.sorting, isNull);
|
||||
expect(TaskColumn.task.sorting, TaskSorting.name);
|
||||
expect(TaskColumn.task.filters, isFalse);
|
||||
expect(TaskColumn.description.filters, isFalse);
|
||||
expect(TaskColumn.labels.filters, isTrue);
|
||||
});
|
||||
|
||||
test('sorting descending keeps tasks without a value last', () {
|
||||
OpenTask task(String id, {Day? deadline}) => (
|
||||
id: id,
|
||||
content: TaskContent(title: id, projectId: 'p', deadline: deadline),
|
||||
);
|
||||
const view = ProjectView(
|
||||
sorting: TaskSorting.deadline,
|
||||
descending: true,
|
||||
);
|
||||
final tasks = [
|
||||
task('none'),
|
||||
task('early', deadline: const Day(2026, 11, 1)),
|
||||
task('late', deadline: const Day(2026, 11, 9)),
|
||||
]..sort(view.compare);
|
||||
|
||||
expect(tasks.map((t) => t.id), ['late', 'early', 'none']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue