feat(todo): a project laid out as a board, its columns its sections (ADR 0033, D17)
The Display's new Layout picks List or Board. The board has a column for the tasks in no section, then one a section with its menu, each task a card and each column ending with Add task, a last column adding a section; grouped otherwise, a column a group. Narrower than a medium width it pages one column at a time with dots below; from it on the columns stand side by side. Cards drag within and between columns as rows do (D16), every scrollable they are in scrolling at its edges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7d1de304c8
commit
e4fbfb54d1
9 changed files with 714 additions and 111 deletions
|
|
@ -202,5 +202,9 @@
|
|||
"viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.",
|
||||
"displaySave": "Speichern",
|
||||
"displayCancel": "Abbrechen",
|
||||
"groupNoLabel": "Ohne Label"
|
||||
"groupNoLabel": "Ohne Label",
|
||||
"layoutTitle": "Layout",
|
||||
"layoutList": "Liste",
|
||||
"layoutBoard": "Board",
|
||||
"boardNoSection": "(Kein Abschnitt)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -608,5 +608,21 @@
|
|||
"groupNoLabel": "No label",
|
||||
"@groupNoLabel": {
|
||||
"description": "A project's group of tasks without a label (ADR 0033, D17)."
|
||||
},
|
||||
"layoutTitle": "Layout",
|
||||
"@layoutTitle": {
|
||||
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
|
||||
},
|
||||
"layoutList": "List",
|
||||
"@layoutList": {
|
||||
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
|
||||
},
|
||||
"layoutBoard": "Board",
|
||||
"@layoutBoard": {
|
||||
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
|
||||
},
|
||||
"boardNoSection": "(No section)",
|
||||
"@boardNoSection": {
|
||||
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1317,6 +1317,30 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'No label'**
|
||||
String get groupNoLabel;
|
||||
|
||||
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Layout'**
|
||||
String get layoutTitle;
|
||||
|
||||
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'List'**
|
||||
String get layoutList;
|
||||
|
||||
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Board'**
|
||||
String get layoutBoard;
|
||||
|
||||
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'(No section)'**
|
||||
String get boardNoSection;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -821,4 +821,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get groupNoLabel => 'Ohne Label';
|
||||
|
||||
@override
|
||||
String get layoutTitle => 'Layout';
|
||||
|
||||
@override
|
||||
String get layoutList => 'Liste';
|
||||
|
||||
@override
|
||||
String get layoutBoard => 'Board';
|
||||
|
||||
@override
|
||||
String get boardNoSection => '(Kein Abschnitt)';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -820,6 +820,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get groupNoLabel => 'No label';
|
||||
|
||||
@override
|
||||
String get layoutTitle => 'Layout';
|
||||
|
||||
@override
|
||||
String get layoutList => 'List';
|
||||
|
||||
@override
|
||||
String get layoutBoard => 'Board';
|
||||
|
||||
@override
|
||||
String get boardNoSection => '(No section)';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -98,6 +98,35 @@ class const _DisplaySheet({required final String projectId})
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
heading(l10n.layoutTitle),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: SegmentedButton<ProjectLayout>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: ProjectLayout.list,
|
||||
icon: const Icon(LucideIcons.list),
|
||||
label: Text(l10n.layoutList),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ProjectLayout.board,
|
||||
icon: const Icon(LucideIcons.squareKanban),
|
||||
label: Text(l10n.layoutBoard),
|
||||
),
|
||||
],
|
||||
// The calendar comes with its own step (D17); until then a
|
||||
// project laid out so shows as a list.
|
||||
selected: {
|
||||
if (view.layout == ProjectLayout.board)
|
||||
ProjectLayout.board
|
||||
else
|
||||
ProjectLayout.list,
|
||||
},
|
||||
onSelectionChanged: (picked) =>
|
||||
_set(context, view.copyWith(layout: picked.single)),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
title: Text(l10n.displayCompleted),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A column of a project's board: its heading, the section it shows (none:
|
||||
/// the tasks in no section, or a group, D17), and its tasks in their order.
|
||||
typedef BoardColumn = ({
|
||||
String title,
|
||||
StoredSection? section,
|
||||
String? sectionId,
|
||||
List<OpenTask> tasks,
|
||||
});
|
||||
|
||||
/// A project's tasks as a board (ADR 0033, D17, as Todoist's): a column a
|
||||
/// section, the tasks in none first, each task a card; on a phone one
|
||||
/// column at a time, paged, the next peeking in and dots below saying
|
||||
/// where; from a medium width on side by side, scrolled across. Where
|
||||
/// [reorder], cards are dragged within and between columns, the board
|
||||
/// scrolling at its edges; a column takes a card or the add button dropped
|
||||
/// below its cards, last in it. Each column ends with Add task; after them,
|
||||
/// where [addSection], a column adds a section.
|
||||
class const ProjectBoard({
|
||||
required final String projectId,
|
||||
required final List<BoardColumn> columns,
|
||||
required final bool reorder,
|
||||
required final bool addSection,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// The key of the dots below the paged columns.
|
||||
static const dotsKey = ValueKey('board-dots');
|
||||
|
||||
@override
|
||||
State<ProjectBoard> createState() => _ProjectBoardState();
|
||||
}
|
||||
|
||||
class _ProjectBoardState() extends State<ProjectBoard> {
|
||||
final _pages = PageController(viewportFraction: 0.88);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Settles on the nearest column once a drag has scrolled the pages
|
||||
/// partway.
|
||||
void _snap() {
|
||||
if (!_pages.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (_pages.page case final page? when (page - page.round()).abs() > 0.01) {
|
||||
_pages.animateToPage(
|
||||
page.round(),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final count = widget.columns.length + (widget.addSection ? 1 : 0);
|
||||
Widget column(int index) => index < widget.columns.length
|
||||
? _Column(
|
||||
projectId: widget.projectId,
|
||||
column: widget.columns[index],
|
||||
reorder: widget.reorder,
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.listPlus),
|
||||
label: Text(l10n.sectionAdd),
|
||||
onPressed: () => addSection(context, widget.projectId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium
|
||||
? _wide(count, column)
|
||||
: _paged(context, count, column),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wide(int count, Widget Function(int index) column) =>
|
||||
ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) =>
|
||||
SizedBox(width: 320, child: column(index)),
|
||||
);
|
||||
|
||||
Widget _paged(
|
||||
BuildContext context,
|
||||
int count,
|
||||
Widget Function(int index) column,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Listener(
|
||||
onPointerUp: (_) => _snap(),
|
||||
child: PageView.builder(
|
||||
controller: _pages,
|
||||
padEnds: false,
|
||||
itemCount: count,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: column(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: _pages,
|
||||
builder: (context, _) {
|
||||
final current = _pages.hasClients
|
||||
? (_pages.page ?? 0).round()
|
||||
: _pages.initialPage;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
key: ProjectBoard.dotsKey,
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < count; index++)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == current
|
||||
? scheme.onSurface
|
||||
: scheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Column({
|
||||
required final String projectId,
|
||||
required final BoardColumn column,
|
||||
required final bool reorder,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final tasks = column.tasks;
|
||||
final section = column.section;
|
||||
// A drop goes last in the column, below the task placed last.
|
||||
final last = tasks.isEmpty
|
||||
? null
|
||||
: tasks.map((t) => t.content.position).reduce((a, b) => a > b ? a : b);
|
||||
Widget card(int index) {
|
||||
final tile = Card.outlined(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: TaskTile(task: tasks[index]),
|
||||
);
|
||||
return reorder
|
||||
? placedTaskRow(
|
||||
state: state,
|
||||
projectId: projectId,
|
||||
sectionId: column.sectionId,
|
||||
tasks: tasks,
|
||||
index: index,
|
||||
child: tile,
|
||||
)
|
||||
: tile;
|
||||
}
|
||||
|
||||
return DragTarget<Object>(
|
||||
onWillAcceptWithDetails: (details) =>
|
||||
details.data is NewTaskDrag || (reorder && details.data is TaskDrag),
|
||||
onAcceptWithDetails: (details) => unawaited(
|
||||
dropAt(
|
||||
context,
|
||||
details.data,
|
||||
projectId,
|
||||
DropSpot(sectionId: column.sectionId, after: last),
|
||||
),
|
||||
),
|
||||
builder: (context, candidates, _) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: candidates.isEmpty
|
||||
? Colors.transparent
|
||||
: theme.colorScheme.primary.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 4),
|
||||
title: Text(
|
||||
'${column.title} '
|
||||
'${tasks.where((t) => !t.content.completed).length}',
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: switch (section) {
|
||||
final section? => SectionMenu(
|
||||
projectId: projectId,
|
||||
section: section,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
for (var index = 0; index < tasks.length; index++)
|
||||
card(index),
|
||||
TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.taskAddTitle),
|
||||
onPressed: () => showQuickAdd(
|
||||
context,
|
||||
projectId: projectId,
|
||||
sectionId: column.sectionId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -83,6 +84,9 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
for (final section in sections)
|
||||
(sectionId: section.id, tasks: kept(sectionId: section.id)),
|
||||
];
|
||||
if (view.layout == ProjectLayout.board) {
|
||||
return _board(context, state, view, sections, groups, today);
|
||||
}
|
||||
if (view.grouping != TaskGrouping.none) {
|
||||
return _grouped(
|
||||
context,
|
||||
|
|
@ -141,6 +145,64 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
);
|
||||
}
|
||||
|
||||
/// The project's tasks as a board (D17): a column for the tasks in no
|
||||
/// section and one a section, or, grouped otherwise, one a group.
|
||||
Widget _board(
|
||||
BuildContext context,
|
||||
TodoState state,
|
||||
ProjectView view,
|
||||
List<StoredSection> sections,
|
||||
List<_Group> groups,
|
||||
Day today,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (view.grouping == TaskGrouping.none) {
|
||||
return ProjectBoard(
|
||||
projectId: projectId,
|
||||
reorder: view.canReorder,
|
||||
addSection: true,
|
||||
columns: [
|
||||
(
|
||||
title: l10n.boardNoSection,
|
||||
section: null,
|
||||
sectionId: null,
|
||||
tasks: groups.first.tasks,
|
||||
),
|
||||
for (final (index, section) in sections.indexed)
|
||||
(
|
||||
title: section.content!.name,
|
||||
section: section,
|
||||
sectionId: section.id,
|
||||
tasks: groups[index + 1].tasks,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final labels = {
|
||||
for (final label in state.labels)
|
||||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
return ProjectBoard(
|
||||
projectId: projectId,
|
||||
reorder: false,
|
||||
addSection: false,
|
||||
columns: [
|
||||
for (final group in groupTasks(
|
||||
[for (final group in groups) ...group.tasks]..sort(view.compare),
|
||||
view.grouping,
|
||||
today: today,
|
||||
labelOrder: [for (final label in state.labels) label.id],
|
||||
))
|
||||
(
|
||||
title: groupTitle(context, group.key, view.grouping, labels, today),
|
||||
section: null,
|
||||
sectionId: null,
|
||||
tasks: group.tasks,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// [tasks] grouped by [view]'s grouping (D17), each group under a heading
|
||||
/// that stays atop the list while its rows scroll beneath it; nothing
|
||||
/// dragged, the order being the grouping's.
|
||||
|
|
@ -166,17 +228,8 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
for (final label in state.labels)
|
||||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
String title(TaskGroupKey key) => switch (key) {
|
||||
OverdueGroup() => l10n.overdue,
|
||||
DayGroup(:final day?) => dayHeading(context, day, today),
|
||||
DayGroup(day: null) => switch (view.grouping) {
|
||||
TaskGrouping.deadline => l10n.rangeNoDeadline,
|
||||
_ => l10n.rangeNoDate,
|
||||
},
|
||||
PriorityGroup(:final priority) => priorityText(l10n, priority),
|
||||
LabelGroup(:final labelId?) => labels[labelId] ?? '',
|
||||
LabelGroup(labelId: null) => l10n.groupNoLabel,
|
||||
};
|
||||
String title(TaskGroupKey key) =>
|
||||
groupTitle(context, key, view.grouping, labels, today);
|
||||
final groups = groupTasks(
|
||||
tasks,
|
||||
view.grouping,
|
||||
|
|
@ -224,39 +277,16 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
int index,
|
||||
OpenTask task, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
if (!reorder) {
|
||||
return TaskTile(task: task);
|
||||
}
|
||||
final tasks = group.tasks;
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
return TaskDropTarget(
|
||||
key: ValueKey('drop-${task.id}'),
|
||||
projectId: projectId,
|
||||
excluded: task.id,
|
||||
above: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
after: index > 0 ? tasks[index - 1].content.position : null,
|
||||
before: task.content.position,
|
||||
),
|
||||
below: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
after: task.content.position,
|
||||
before: index + 1 < tasks.length
|
||||
? tasks[index + 1].content.position
|
||||
: null,
|
||||
),
|
||||
under: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
parentId: task.id,
|
||||
after: subTasks.lastOrNull?.content.position,
|
||||
),
|
||||
child: DraggableTask(
|
||||
task: task,
|
||||
child: TaskTile(task: task),
|
||||
),
|
||||
);
|
||||
}
|
||||
}) => reorder
|
||||
? placedTaskRow(
|
||||
state: state,
|
||||
projectId: projectId,
|
||||
sectionId: group.sectionId,
|
||||
tasks: group.tasks,
|
||||
index: index,
|
||||
child: TaskTile(task: group.tasks[index]),
|
||||
)
|
||||
: TaskTile(task: task);
|
||||
|
||||
Widget _section(
|
||||
BuildContext context,
|
||||
|
|
@ -266,7 +296,6 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
_Group group, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = section.content!;
|
||||
final tasks = group.tasks;
|
||||
|
|
@ -282,52 +311,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: PopupMenuButton<_SectionAction>(
|
||||
tooltip: l10n.sectionMenu,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onSelected: (action) async {
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
switch (action) {
|
||||
case _SectionAction.add:
|
||||
await showQuickAdd(
|
||||
context,
|
||||
projectId: projectId,
|
||||
sectionId: section.id,
|
||||
);
|
||||
case _SectionAction.edit:
|
||||
if (await editSection(context, content) case final edited?) {
|
||||
await todo.sections.update(section.id, edited);
|
||||
}
|
||||
case _SectionAction.delete:
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.sectionDeleteTitle,
|
||||
message: l10n.sectionDeleteMessage(content.name),
|
||||
confirmLabel: l10n.sectionDelete,
|
||||
)) {
|
||||
await todo.deleteSection(projectId, section.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.add,
|
||||
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.edit,
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.delete,
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.sectionDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SectionMenu(projectId: projectId, section: section),
|
||||
);
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
|
|
@ -362,6 +346,127 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
}
|
||||
}
|
||||
|
||||
/// A section's menu (D8): a task added there, the section renamed or
|
||||
/// deleted with its tasks, which asks first.
|
||||
class const SectionMenu({
|
||||
required final String projectId,
|
||||
required final StoredSection section,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final content = section.content!;
|
||||
return PopupMenuButton<_SectionAction>(
|
||||
tooltip: l10n.sectionMenu,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onSelected: (action) async {
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
switch (action) {
|
||||
case _SectionAction.add:
|
||||
await showQuickAdd(
|
||||
context,
|
||||
projectId: projectId,
|
||||
sectionId: section.id,
|
||||
);
|
||||
case _SectionAction.edit:
|
||||
if (await editSection(context, content) case final edited?) {
|
||||
await todo.sections.update(section.id, edited);
|
||||
}
|
||||
case _SectionAction.delete:
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.sectionDeleteTitle,
|
||||
message: l10n.sectionDeleteMessage(content.name),
|
||||
confirmLabel: l10n.sectionDelete,
|
||||
)) {
|
||||
await todo.deleteSection(projectId, section.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.add,
|
||||
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.edit,
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.delete,
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.sectionDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The [index]th of [tasks], those of [sectionId] in [projectId] in their
|
||||
/// order, as [child], held to be dragged into another place and taking a
|
||||
/// drop above it, below it, or under it as its sub-task (D16).
|
||||
Widget placedTaskRow({
|
||||
required TodoState state,
|
||||
required String projectId,
|
||||
required String? sectionId,
|
||||
required List<OpenTask> tasks,
|
||||
required int index,
|
||||
required Widget child,
|
||||
}) {
|
||||
final task = tasks[index];
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
return TaskDropTarget(
|
||||
key: ValueKey('drop-${task.id}'),
|
||||
projectId: projectId,
|
||||
excluded: task.id,
|
||||
above: DropSpot(
|
||||
sectionId: sectionId,
|
||||
after: index > 0 ? tasks[index - 1].content.position : null,
|
||||
before: task.content.position,
|
||||
),
|
||||
below: DropSpot(
|
||||
sectionId: sectionId,
|
||||
after: task.content.position,
|
||||
before: index + 1 < tasks.length
|
||||
? tasks[index + 1].content.position
|
||||
: null,
|
||||
),
|
||||
under: DropSpot(
|
||||
sectionId: sectionId,
|
||||
parentId: task.id,
|
||||
after: subTasks.lastOrNull?.content.position,
|
||||
),
|
||||
child: DraggableTask(task: task, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
/// The heading of the group [key] of tasks grouped by [grouping] on
|
||||
/// [today] (D17), [labels] naming the labels by their IDs.
|
||||
String groupTitle(
|
||||
BuildContext context,
|
||||
TaskGroupKey key,
|
||||
TaskGrouping grouping,
|
||||
Map<String, String> labels,
|
||||
Day today,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return switch (key) {
|
||||
OverdueGroup() => l10n.overdue,
|
||||
DayGroup(:final day?) => dayHeading(context, day, today),
|
||||
DayGroup(day: null) => switch (grouping) {
|
||||
TaskGrouping.deadline => l10n.rangeNoDeadline,
|
||||
_ => l10n.rangeNoDate,
|
||||
},
|
||||
PriorityGroup(:final priority) => priorityText(l10n, priority),
|
||||
LabelGroup(:final labelId?) => labels[labelId] ?? '',
|
||||
LabelGroup(labelId: null) => l10n.groupNoLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/// A group of a project's list: the tasks of a section, or of none.
|
||||
typedef _Group = ({String? sectionId, List<OpenTask> tasks});
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,8 @@ class _TaskDropTargetState() extends State<TaskDropTarget> {
|
|||
|
||||
/// A task's row dragged after a long press (D16), into another place in
|
||||
/// its project, or a little to the right to become a sub-task, the list
|
||||
/// scrolling at its edges; not while tasks are being chosen.
|
||||
/// scrolling at its edges, and a board's columns too at theirs (D17); not
|
||||
/// while tasks are being chosen.
|
||||
class const DraggableTask({
|
||||
required final OpenTask task,
|
||||
required final Widget child,
|
||||
|
|
@ -197,11 +198,15 @@ class const DraggableTask({
|
|||
|
||||
class _DraggableTaskState() extends State<DraggableTask> {
|
||||
late final _drag = TaskDrag(widget.task.id);
|
||||
EdgeDraggingAutoScroller? _scroller;
|
||||
|
||||
/// One for each scrollable the row is in, innermost first.
|
||||
var _scrollers = <EdgeDraggingAutoScroller>[];
|
||||
|
||||
void _stop() {
|
||||
_scroller?.stopAutoScroll();
|
||||
_scroller = null;
|
||||
for (final scroller in _scrollers) {
|
||||
scroller.stopAutoScroll();
|
||||
}
|
||||
_scrollers = [];
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -223,20 +228,26 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
|||
data: _drag,
|
||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||
onDragStarted: () {
|
||||
if (Scrollable.maybeOf(context) case final scrollable?) {
|
||||
_scroller = EdgeDraggingAutoScroller(
|
||||
scrollable,
|
||||
velocityScalar: 20,
|
||||
_scrollers = [
|
||||
for (
|
||||
var scrollable = Scrollable.maybeOf(context);
|
||||
scrollable != null;
|
||||
scrollable = Scrollable.maybeOf(scrollable.context)
|
||||
)
|
||||
EdgeDraggingAutoScroller(scrollable, velocityScalar: 20),
|
||||
];
|
||||
},
|
||||
onDragUpdate: (details) {
|
||||
for (final scroller in _scrollers) {
|
||||
scroller.startAutoScrollIfNecessary(
|
||||
Rect.fromCenter(
|
||||
center: details.globalPosition,
|
||||
width: 1,
|
||||
height: 64,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDragUpdate: (details) => _scroller?.startAutoScrollIfNecessary(
|
||||
Rect.fromCenter(
|
||||
center: details.globalPosition,
|
||||
width: 1,
|
||||
height: 64,
|
||||
),
|
||||
),
|
||||
onDragEnd: (_) => _stop(),
|
||||
onDraggableCanceled: (_, _) => _stop(),
|
||||
onDragCompleted: _stop,
|
||||
|
|
|
|||
Loading…
Reference in a new issue