feat(todo): a project's editor as Todoist's Add project, with sub-projects and favourites

Adding or editing a project was a dialog of a name and a colour. It is
now a screen of its own on a phone (a dialog where sheets are dialogs),
as Todoist's Add project: the name counted to 120, a description, the
colour, the parent project, a Favourite switch and the layout, Display's
pictures. All of it lives in the project's sealed payload, the schema
still 1, no server model or migration; the payload now keeps fields it
doesn't know, as a task's does.

A new project starts with the layout picked, and Display's Reset all
returns to that default rather than to List. Browse lists sub-projects
indented and folding under their parents, and the favourites in a group
above; a project is never offered as its own ancestor, and a parent's
deletion takes its sub-projects, naming them first, as Todoist's. ADR
0033, D28.

Whats-New: Add project and Edit project are a screen of their own, with a description, a parent project, Favourite and the layout.
Whats-New: Browse lists sub-projects under their parents and your favourite projects at the top.
Whats-New: Reset all in Display returns to the layout the project was made with.
Whats-New-De: „Projekt hinzufügen“ und „Projekt bearbeiten“ sind eine eigene Seite, mit Beschreibung, übergeordnetem Projekt, Favorit und Layout.
Whats-New-De: „Übersicht“ zeigt Unterprojekte unter ihren Projekten und deine Favoriten oben.
Whats-New-De: „Alles zurücksetzen“ in der Anzeige kehrt zum Layout zurück, mit dem das Projekt angelegt wurde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-04 20:56:34 +01:00
commit d0e72cf095
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1094 additions and 93 deletions

File diff suppressed because one or more lines are too long

View file

@ -51,11 +51,18 @@
"priority4": "Keine Priorität",
"projectsTitle": "Projekte",
"projectsNone": "Noch keine eigenen Projekte.",
"projectAdd": "Neues Projekt",
"projectAdd": "Projekt hinzufügen",
"projectEdit": "Projekt bearbeiten",
"projectDelete": "Projekt löschen",
"projectDeleteTitle": "Projekt löschen?",
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
"projectParent": "Übergeordnetes Projekt",
"projectNoParent": "Kein übergeordnetes Projekt",
"projectFavorite": "Favorit",
"favoritesTitle": "Favoriten",
"projectSubProjectsHide": "Unterprojekte ausblenden",
"projectSubProjectsShow": "Unterprojekte einblenden",
"projectDeleteWithSubProjects": "„{name}“ wird mit seinen Unterprojekten {subProjects} und ihren Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
"projectEmptyTitle": "Hier ist nichts zu tun",
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
"projectGone": "Dieses Projekt wurde gelöscht.",

View file

@ -106,7 +106,7 @@
"priority4": "No priority",
"projectsTitle": "Projects",
"projectsNone": "No projects of your own yet.",
"projectAdd": "New project",
"projectAdd": "Add project",
"projectEdit": "Edit project",
"projectDelete": "Delete project",
"projectDeleteTitle": "Delete the project?",
@ -118,6 +118,27 @@
}
}
},
"projectParent": "Parent project",
"@projectParent": {
"description": "A project's editor (ADR 0033, D28), as Todoist's Add project."
},
"projectNoParent": "No parent",
"projectFavorite": "Favourite",
"favoritesTitle": "Favourites",
"projectSubProjectsHide": "Hide sub-projects",
"projectSubProjectsShow": "Show sub-projects",
"projectDeleteWithSubProjects": "\"{name}\" is deleted with its sub-projects {subProjects}, and their sections and tasks, on all your devices.",
"@projectDeleteWithSubProjects": {
"description": "Deleting a project with sub-projects (ADR 0033, D28): subProjects their names, separated by commas.",
"placeholders": {
"name": {
"type": "String"
},
"subProjects": {
"type": "String"
}
}
},
"projectEmptyTitle": "Nothing to do here",
"projectEmptyHint": "Add a task with the + button.",
"projectGone": "This project was deleted.",

View file

@ -1,3 +1,5 @@
{
"@@locale": "en_US"
"@@locale": "en_US",
"projectFavorite": "Favorite",
"favoritesTitle": "Favorites"
}

View file

@ -409,7 +409,7 @@ abstract class AppLocalizations {
/// No description provided for @projectAdd.
///
/// In en, this message translates to:
/// **'New project'**
/// **'Add project'**
String get projectAdd;
/// No description provided for @projectEdit.
@ -436,6 +436,48 @@ abstract class AppLocalizations {
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
String projectDeleteMessage(String name);
/// A project's editor (ADR 0033, D28), as Todoist's Add project.
///
/// In en, this message translates to:
/// **'Parent project'**
String get projectParent;
/// No description provided for @projectNoParent.
///
/// In en, this message translates to:
/// **'No parent'**
String get projectNoParent;
/// No description provided for @projectFavorite.
///
/// In en, this message translates to:
/// **'Favourite'**
String get projectFavorite;
/// No description provided for @favoritesTitle.
///
/// In en, this message translates to:
/// **'Favourites'**
String get favoritesTitle;
/// No description provided for @projectSubProjectsHide.
///
/// In en, this message translates to:
/// **'Hide sub-projects'**
String get projectSubProjectsHide;
/// No description provided for @projectSubProjectsShow.
///
/// In en, this message translates to:
/// **'Show sub-projects'**
String get projectSubProjectsShow;
/// Deleting a project with sub-projects (ADR 0033, D28): subProjects their names, separated by commas.
///
/// In en, this message translates to:
/// **'\"{name}\" is deleted with its sub-projects {subProjects}, and their sections and tasks, on all your devices.'**
String projectDeleteWithSubProjects(String name, String subProjects);
/// No description provided for @projectEmptyTitle.
///
/// In en, this message translates to:

View file

@ -191,7 +191,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get projectsNone => 'Noch keine eigenen Projekte.';
@override
String get projectAdd => 'Neues Projekt';
String get projectAdd => 'Projekt hinzufügen';
@override
String get projectEdit => 'Projekt bearbeiten';
@ -207,6 +207,29 @@ class AppLocalizationsDe extends AppLocalizations {
return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
}
@override
String get projectParent => 'Übergeordnetes Projekt';
@override
String get projectNoParent => 'Kein übergeordnetes Projekt';
@override
String get projectFavorite => 'Favorit';
@override
String get favoritesTitle => 'Favoriten';
@override
String get projectSubProjectsHide => 'Unterprojekte ausblenden';
@override
String get projectSubProjectsShow => 'Unterprojekte einblenden';
@override
String projectDeleteWithSubProjects(String name, String subProjects) {
return '„$name“ wird mit seinen Unterprojekten $subProjects und ihren Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
}
@override
String get projectEmptyTitle => 'Hier ist nichts zu tun';

View file

@ -191,7 +191,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get projectsNone => 'No projects of your own yet.';
@override
String get projectAdd => 'New project';
String get projectAdd => 'Add project';
@override
String get projectEdit => 'Edit project';
@ -207,6 +207,29 @@ class AppLocalizationsEn extends AppLocalizations {
return '\"$name\" is deleted with its sections and tasks, on all your devices.';
}
@override
String get projectParent => 'Parent project';
@override
String get projectNoParent => 'No parent';
@override
String get projectFavorite => 'Favourite';
@override
String get favoritesTitle => 'Favourites';
@override
String get projectSubProjectsHide => 'Hide sub-projects';
@override
String get projectSubProjectsShow => 'Show sub-projects';
@override
String projectDeleteWithSubProjects(String name, String subProjects) {
return '\"$name\" is deleted with its sub-projects $subProjects, and their sections and tasks, on all your devices.';
}
@override
String get projectEmptyTitle => 'Nothing to do here';
@ -995,4 +1018,10 @@ class AppLocalizationsEnGb extends AppLocalizationsEn {
/// The translations for English, as used in the United States (`en_US`).
class AppLocalizationsEnUs extends AppLocalizationsEn {
AppLocalizationsEnUs() : super('en_US');
@override
String get projectFavorite => 'Favorite';
@override
String get favoritesTitle => 'Favorites';
}

View file

@ -14,6 +14,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
@ -62,14 +63,18 @@ class TodoPerson(
final ReminderGateway reminders = const NoReminders(),
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Deletes the project [id] with its sections and tasks; throws
/// Deletes the project [id] with its sections and tasks, and its
/// sub-projects with theirs, as Todoist does (ADR 0033, D28); throws
/// [InboxKept] for the Inbox, which keeps everything.
Future<void> deleteProject(String id) async {
await projects.delete(id);
await tasks.deleteIn(id);
final ids = [id, ...descendantsOf(await projects.all(), id)];
for (final project in ids) {
await projects.delete(project);
await tasks.deleteIn(project);
}
await sections.delete([
for (final section in await sections.all())
if (section.content?.projectId == id) section.id,
if (ids.contains(section.content?.projectId)) section.id,
]);
}

View file

@ -6,18 +6,42 @@ import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a project's payload this app writes and reads.
/// The schema of a project's payload this app writes and reads. It stays 1
/// as fields are added (D17, D28): an older version reads a project with
/// them, and keeps them as it writes the project once it knows to
/// ([ProjectContent.unknown], since D28).
const projectSchemaVersion = 1;
/// The fields of a project's payload this version reads; any other is
/// kept as it was (D28).
const _known = {
'schemaVersion',
'name',
'color',
'position',
'view',
'description',
'parentId',
'favorite',
'defaultView',
};
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
/// its palette name.
/// its palette name; what a newer version wrote and this one doesn't know
/// as it was, beneath what this one writes.
ByteData encodeProject(ProjectContent content) =>
encodeJson(projectSchemaVersion, {
...content.unknown,
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
// As at first, nothing: a project's Display is written once set (D17).
if (!content.view.isDefault) 'view': encodeView(content.view),
if (content.description.isNotEmpty) 'description': content.description,
'parentId': ?content.parentId,
if (content.favorite) 'favorite': true,
if (!content.defaultView.isDefault)
'defaultView': encodeView(content.defaultView),
});
/// [view] by its values' names, those left as at first left out.
@ -62,7 +86,9 @@ ProjectView decodeView(Object? json) {
}
/// The project [payload] holds, opened; null for one of a newer schema or one
/// it can't read. A colour it doesn't know is none.
/// it can't read. A colour it doesn't know is none; a payload of a version
/// before D28 is a project of its own, no favourite, without a
/// description, its Display's defaults as at first.
ProjectContent? decodeProject(ByteData payload) => decodeJson(
payload,
version: projectSchemaVersion,
@ -72,5 +98,13 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
view: decodeView(json['view']),
description: json['description'] as String? ?? '',
parentId: json['parentId'] as String?,
favorite: json['favorite'] == true,
defaultView: decodeView(json['defaultView']),
unknown: {
for (final MapEntry(:key, :value) in json.entries)
if (!_known.contains(key)) key: value,
},
),
);

View file

@ -5,9 +5,11 @@ import 'package:halkyon_ui/halkyon_ui.dart';
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
/// its [position] among the person's projects, smaller first, and how it
/// shows its tasks ([view], D17). The Inbox
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
/// shows under the app's own word for it, whatever [name] holds.
/// shows its tasks ([view], D17); since D28 its [description], the project
/// it is a sub-project of ([parentId]), whether it is a [favorite], and
/// the Display its Reset returns to ([defaultView]). The Inbox is told by
/// its row's ID, not by anything here (`ProjectRepository`), and shows
/// under the app's own word for it, whatever [name] holds.
final class const ProjectContent({
required final String name,
final PaletteColor? color,
@ -15,22 +17,65 @@ final class const ProjectContent({
/// How the project shows its tasks (D17).
final ProjectView view = const ProjectView(),
/// What the project is for, below its name in its editor (D28).
final String description = '',
/// The project it is a sub-project of (D28); none: a project of its own.
final String? parentId,
/// Whether Browse lists it among the Favorites too (D28).
final bool favorite = false,
/// The Display it is made with and its Reset returns to (D28): its
/// layout as its editor picks it, the rest as at first.
final ProjectView defaultView = const ProjectView(),
/// What its payload holds that this version doesn't know (as a task's,
/// D18): written back as it was, so that a project changed here keeps
/// what a newer version gave it.
final Map<String, Object?> unknown = const {},
}) with Equatable {
/// The Inbox's content, as the first device writes it.
static const inbox = ProjectContent(name: '');
/// The longest name its editor takes (D28, as Todoist's).
static const maxNameLength = 120;
/// Whether its Display is the one it was made with (D28).
bool get viewIsDefault => view == defaultView;
ProjectContent copyWith({
String? name,
PaletteColor? Function()? color,
double? position,
ProjectView? view,
String? description,
String? Function()? parentId,
bool? favorite,
ProjectView? defaultView,
}) => ProjectContent(
name: name ?? this.name,
color: color == null ? this.color : color(),
position: position ?? this.position,
view: view ?? this.view,
description: description ?? this.description,
parentId: parentId == null ? this.parentId : parentId(),
favorite: favorite ?? this.favorite,
defaultView: defaultView ?? this.defaultView,
unknown: unknown,
);
@override
List<Object?> get props => [name, color, position, view];
List<Object?> get props => [
name,
color,
position,
view,
description,
parentId,
favorite,
defaultView,
unknown,
];
}

View file

@ -0,0 +1,85 @@
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
/// A project as Browse lists it (ADR 0033, D28): how deep it stands under
/// the projects it is a sub-project of, and whether it has sub-projects.
typedef ProjectNode = ({StoredProject project, int depth, bool hasChildren});
/// The person's own projects (not the Inbox, none that doesn't open) that
/// can be listed: [projects] without those.
List<StoredProject> _listed(List<StoredProject> projects) => [
for (final project in projects)
if (!project.inbox && project.content != null) project,
];
/// [projects] as Browse lists them (D28): each project of its own, in
/// their order, each followed by its sub-projects, one level deeper. A
/// project whose parent isn't listed (deleted, not opening here, or the
/// Inbox) stands as a project of its own; so do projects whose parents
/// run in a circle, as two devices could make them each moving one under
/// the other, so that none is lost.
List<ProjectNode> projectTree(List<StoredProject> projects) {
final listed = _listed(projects);
final ids = {for (final project in listed) project.id};
final children = <String, List<StoredProject>>{};
for (final project in listed) {
if (project.content!.parentId case final parent?
when ids.contains(parent)) {
(children[parent] ??= []).add(project);
}
}
final tree = <ProjectNode>[];
final placed = <String>{};
void place(StoredProject project, int depth) {
if (!placed.add(project.id)) {
return;
}
final below = children[project.id] ?? const [];
tree.add((project: project, depth: depth, hasChildren: below.isNotEmpty));
for (final child in below) {
place(child, depth + 1);
}
}
for (final project in listed) {
if (!ids.contains(project.content!.parentId)) {
place(project, 0);
}
}
// What is left stands in a circle: each a project of its own.
for (final project in listed) {
place(project, 0);
}
return tree;
}
/// The IDs of the project [id]'s sub-projects, theirs too, at every depth.
Set<String> descendantsOf(List<StoredProject> projects, String id) {
final found = <String>{};
var frontier = {id};
while (frontier.isNotEmpty) {
frontier = {
for (final project in projects)
if (frontier.contains(project.content?.parentId) &&
project.id != id &&
found.add(project.id))
project.id,
};
}
return found;
}
/// The projects the project [id] can be moved under (D28): every one of
/// the person's own but itself and its sub-projects at every depth, which
/// would make it its own parent; for a new project ([id] null), every one.
List<StoredProject> parentChoices(List<StoredProject> projects, String? id) {
final excluded = id == null
? const <String>{}
: {
id,
...descendantsOf(projects, id),
};
return [
for (final node in projectTree(projects))
if (!excluded.contains(node.project.id)) node.project,
];
}

View file

@ -69,25 +69,23 @@ DisplayTarget projectDisplay(String projectId) {
return DisplayTarget(
viewOf: viewOf,
write: write,
isDefault: (state) => viewOf(state).isDefault,
reset: (todo, state) => write(todo, state, const ProjectView()),
// Reset returns to the Display the project was made with (D28).
isDefault: (state) =>
state.projectOf(projectId)?.content?.viewIsDefault ?? true,
reset: (todo, state) => write(
todo,
state,
state.projectOf(projectId)?.content?.defaultView ?? const ProjectView(),
),
grouping: (state) => !calendar(state),
occurrences: calendar,
layout: (context, state, apply) {
final l10n = AppLocalizations.of(context);
final view = viewOf(state);
return [
_LayoutPicker<ProjectLayout>(
LayoutPicker<ProjectLayout>(
selected: view.layout,
segments: [
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
(
ProjectLayout.calendar,
LucideIcons.calendarDays,
l10n.layoutCalendar,
),
],
segments: projectLayouts(l10n),
onPicked: (layout) => apply(
(todo) => write(todo, state, view.copyWith(layout: layout)),
),
@ -125,7 +123,7 @@ final upcomingDisplay = DisplayTarget(
(todo) => todo.settings.write(state.settings.copyWith(upcoming: view)),
);
return [
_LayoutPicker<UpcomingLayout>(
LayoutPicker<UpcomingLayout>(
selected: upcoming.layout,
segments: [
(UpcomingLayout.agenda, LucideIcons.list, l10n.layoutAgenda),
@ -257,8 +255,8 @@ class const _DisplaySheet({required final DisplayTarget target})
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(l10n.layoutTitle),
_Group(children: target.layout(context, state, apply)),
_Group(
RoundedGroup(children: target.layout(context, state, apply)),
RoundedGroup(
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
@ -276,7 +274,7 @@ class const _DisplaySheet({required final DisplayTarget target})
],
),
heading(l10n.displaySort),
_Group(
RoundedGroup(
children: [
if (target.grouping(state))
choice(
@ -316,7 +314,7 @@ class const _DisplaySheet({required final DisplayTarget target})
],
),
heading(l10n.displayFilter),
_Group(
RoundedGroup(
children: [
if (target.dateFilter)
choice(
@ -412,10 +410,22 @@ class const _DisplaySheet({required final DisplayTarget target})
/// 3 Nov 2026): each a phone's outline with its layout's picture in it,
/// its name below on one line, and a radio button marking the one in use;
/// the picture, the name or the button picks it.
class const _LayoutPicker<T>({
/// 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),
];
/// 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.
class const LayoutPicker<T>({
required final T selected,
required final List<(T, IconData, String)> segments,
required final ValueChanged<T> onPicked,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -481,8 +491,9 @@ class const _LayoutPicker<T>({
}
/// [children] in a box rounded at its ends, as Todoist's Display groups
/// its rows (the developer's review, 3 Nov 2026).
class const _Group({required final List<Widget> children})
/// its rows (the developer's review, 3 Nov 2026), and a project's editor
/// its own (D28).
class const RoundedGroup({required final List<Widget> children, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(

View file

@ -1,35 +1,9 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a project's name and colour (ADR 0033, D9), [current]'s to
/// change, or a new one's; null where the person changed their mind.
Future<ProjectContent?> editProject(
BuildContext context, {
ProjectContent? current,
}) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current == null ? l10n.projectAdd : l10n.projectEdit,
name: current?.name ?? '',
color: current?.color ?? PaletteColor.cobalt,
);
return switch (named) {
// The rest kept as it is: building it anew had reset the project's
// Display (the developer's review, 4 Nov 2026).
(:final name, :final color) =>
(current ?? ProjectContent(name: name)).copyWith(
name: name,
color: () => color,
),
null => null,
};
}
/// Asks for a label's name and colour, [current]'s to change, or a new
/// one's; null where the person changed their mind.
Future<LabelContent?> editLabel(

View file

@ -0,0 +1,299 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a project as Todoist's Add project does (ADR 0033, D28): its
/// name, description, colour, parent among [projects], whether it is a
/// favourite, and its layout; [current]'s, the project [id], to change, or a
/// new one's. Null where the person changed their mind.
///
/// A screen of its own on a phone, with ← and ✓ in its app bar; where
/// sheets are dialogs (from expanded windows on, and on the desktop), the
/// same in a dialog, as the app's other editors.
Future<ProjectContent?> editProject(
BuildContext context, {
required List<StoredProject> projects,
String? id,
ProjectContent? current,
}) {
final editor = _ProjectEditor(id: id, current: current, projects: projects);
if (sheetShowsAsDialog(context)) {
return showDialog<ProjectContent>(
context: context,
builder: (context) => Dialog(
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
child: editor,
),
),
);
}
return Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<ProjectContent>(
fullscreenDialog: true,
builder: (context) => editor,
),
);
}
class const _ProjectEditor({
required final String? id,
required final ProjectContent? current,
required final List<StoredProject> projects,
}) extends StatefulWidget {
@override
State<_ProjectEditor> createState() => _ProjectEditorState();
}
class _ProjectEditorState() extends State<_ProjectEditor> {
late final _name = TextEditingController(text: widget.current?.name);
late final _description = TextEditingController(
text: widget.current?.description,
);
late var _color = widget.current?.color ?? PaletteColor.cobalt;
late var _parentId = widget.current?.parentId;
late var _favorite = widget.current?.favorite ?? false;
/// The layout it is made with, which its Display's Reset returns to.
late var _layout = widget.current?.defaultView.layout ?? ProjectLayout.list;
@override
void initState() {
super.initState();
// ✓ is on once there is a name.
_name.addListener(() => setState(() {}));
}
@override
void dispose() {
_name.dispose();
_description.dispose();
super.dispose();
}
void _save() {
final name = _name.text.trim();
if (name.isEmpty) {
return;
}
final description = _description.text.trim();
final current = widget.current;
Navigator.of(context).pop(switch (current) {
// A new project starts with its layout (D28).
null => ProjectContent(
name: name,
color: _color,
description: description,
parentId: _parentId,
favorite: _favorite,
view: ProjectView(layout: _layout),
defaultView: ProjectView(layout: _layout),
),
// The rest kept as it is, its Display too (the developer's review,
// 4 Nov 2026); a layout picked anew shows at once.
final current => current.copyWith(
name: name,
color: () => _color,
description: description,
parentId: () => _parentId,
favorite: _favorite,
view: _layout == current.defaultView.layout
? null
: current.view.copyWith(layout: _layout),
defaultView: current.defaultView.copyWith(layout: _layout),
),
});
}
StoredProject? get _parent => switch (_parentId) {
final id? => widget.projects.where((p) => p.id == id).firstOrNull,
null => null,
};
Future<void> _pickColor() async {
final l10n = AppLocalizations.of(context);
final picked = await showDialog<PaletteColor>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.colorLabel),
content: SizedBox(
width: 320,
child: PaletteColorPicker(
selected: _color,
onChanged: (color) => Navigator.of(context).pop(color),
),
),
),
);
if (picked != null) {
setState(() => _color = picked);
}
}
Future<void> _pickParent() async {
final picked = await pickParentProject(
context,
projects: widget.projects,
id: widget.id,
current: _parentId,
);
if (picked case (:final parentId)) {
setState(() => _parentId = parentId);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final ui = HalkyonUiLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final canSave = _name.text.trim().isNotEmpty;
return Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
widget.current == null ? l10n.projectAdd : l10n.projectEdit,
),
actions: [
IconButton(
tooltip: l10n.save,
icon: const Icon(LucideIcons.check),
onPressed: canSave ? _save : null,
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _name,
autofocus: widget.current == null,
maxLength: ProjectContent.maxNameLength,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.nameLabel),
onSubmitted: (_) => _save(),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: TextField(
controller: _description,
minLines: 1,
maxLines: 5,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskDescription),
),
),
RoundedGroup(
children: [
ListTile(
leading: Icon(
LucideIcons.palette,
color: paletteText(context, _color),
),
title: Text(l10n.colorLabel),
trailing: Text(
ui.paletteColorName(_color.name),
style: TextStyle(color: muted),
),
onTap: _pickColor,
),
ListTile(
leading: const Icon(LucideIcons.folderTree),
title: Text(l10n.projectParent),
trailing: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
_parent?.content?.name ?? l10n.projectNoParent,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: muted),
),
),
onTap: _pickParent,
),
SwitchListTile(
secondary: const Icon(LucideIcons.heart),
title: Text(l10n.projectFavorite),
value: _favorite,
onChanged: (on) => setState(() => _favorite = on),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(l10n.layoutTitle, style: theme.textTheme.titleSmall),
),
RoundedGroup(
children: [
LayoutPicker<ProjectLayout>(
selected: _layout,
segments: projectLayouts(l10n),
onPicked: (layout) => setState(() => _layout = layout),
),
],
),
],
),
);
}
}
/// Asks under which project the project [id] (none: a new one) goes, none
/// for a project of its own, [current] ticked: every project of the
/// person's but itself and its sub-projects (D28), indented as Browse
/// lists them. Null where the person changed their mind.
Future<({String? parentId})?> pickParentProject(
BuildContext context, {
required List<StoredProject> projects,
required String? id,
required String? current,
}) => showAdaptiveSheet<({String? parentId})>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final choices = {for (final p in parentChoices(projects, id)) p.id};
Widget option(String? parentId, String name, {int depth = 0}) => ListTile(
contentPadding: EdgeInsetsDirectional.only(start: 16 + 24.0 * depth),
leading: Icon(
parentId == null ? LucideIcons.circleSlash : LucideIcons.hash,
),
title: Text(name),
trailing: parentId == current ? const Icon(LucideIcons.check) : null,
onTap: () =>
Navigator.of(context).pop<({String? parentId})>((parentId: parentId)),
);
return SheetScaffold(
title: l10n.projectParent,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
option(null, l10n.projectNoParent),
for (final node in projectTree(projects))
if (choices.contains(node.project.id))
option(
node.project.id,
node.project.content!.name,
depth: node.depth,
),
],
),
);
},
);

View file

@ -4,8 +4,9 @@ import 'package:halkyon_app/halkyon_app.dart';
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_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
@ -110,7 +111,12 @@ class const ProjectScreen({required final String projectId, super.key})
PopupMenuItem(
value: () async {
final todo = PersonScope.of<TodoPerson>(context);
if (await editProject(context, current: content)
if (await editProject(
context,
projects: state.projects,
id: projectId,
current: content,
)
case final edited?) {
await todo.projects.update(projectId, edited);
}
@ -124,7 +130,22 @@ class const ProjectScreen({required final String projectId, super.key})
if (await confirmDestructiveAction(
context,
title: l10n.projectDeleteTitle,
message: l10n.projectDeleteMessage(content.name),
// Its sub-projects go with it, as Todoist's (D28),
// named here first.
message: switch ([
for (final node in projectTree(state.projects))
if (descendantsOf(
state.projects,
projectId,
).contains(node.project.id))
node.project.content!.name,
]) {
[] => l10n.projectDeleteMessage(content.name),
final names => l10n.projectDeleteWithSubProjects(
content.name,
names.join(', '),
),
},
confirmLabel: l10n.projectDelete,
)) {
await todo.deleteProject(projectId);

View file

@ -4,7 +4,10 @@ import 'package:halkyon_app/halkyon_app.dart';
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/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.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/task_texts.dart';
@ -19,8 +22,19 @@ import 'package:material_ui/material_ui.dart';
/// person's projects, each in its colour with
/// how many of its tasks are open, one opening its screen, and one more to
/// add; their labels, to add, rename or delete; the cog to Settings. What
/// doesn't open on this device is counted at the foot.
class const BrowseTab({super.key}) extends StatelessWidget {
/// doesn't open on this device is counted at the foot. Since D28, as
/// Todoist's: the favourite projects in a group of their own above, and
/// each project's sub-projects indented under it, folding away under its
/// chevron (not kept: each start shows them all).
class const BrowseTab({super.key}) extends StatefulWidget {
@override
State<BrowseTab> createState() => _BrowseTabState();
}
class _BrowseTabState() extends State<BrowseTab> {
/// The projects whose sub-projects are folded away.
final _folded = <String>{};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -47,6 +61,53 @@ class const BrowseTab({super.key}) extends StatelessWidget {
for (final project in state.projects)
if (!project.inbox && project.content != null) project,
];
final tree = projectTree(state.projects);
final favorites = [
for (final project in projects)
if (project.content!.favorite) project,
];
// Hidden: those under a folded project, however deep.
final hidden = {
for (final id in _folded) ...descendantsOf(state.projects, id),
};
Widget projectRow(StoredProject project, {int depth = 0, bool? folded}) =>
ListTile(
contentPadding: EdgeInsetsDirectional.only(
start: 16 + 24.0 * depth,
end: folded == null ? 16 : 4,
),
leading: Icon(
LucideIcons.hash,
color: switch (project.content!.color) {
final color? => paletteText(context, color),
null => null,
},
),
title: Text(project.content!.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (openCountIn(state.tasks, project.id) case final count
when count > 0)
Text('$count'),
if (folded != null)
IconButton(
tooltip: folded
? l10n.projectSubProjectsShow
: l10n.projectSubProjectsHide,
icon: Icon(
folded ? LucideIcons.chevronRight : LucideIcons.chevronDown,
),
onPressed: () => setState(
() => folded
? _folded.remove(project.id)
: _folded.add(project.id),
),
),
],
),
onTap: () => context.go(TodoPaths.project(project.id)),
);
return TabScreen(
title: l10n.shellTabBrowse,
actions: const [SettingsButton()],
@ -77,6 +138,26 @@ class const BrowseTab({super.key}) extends StatelessWidget {
),
),
const SizedBox(height: 12),
if (favorites.isNotEmpty) ...[
SectionCard(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
l10n.favoritesTitle,
style: theme.textTheme.titleMedium,
),
),
for (final project in favorites)
projectRow(project),
],
),
),
const SizedBox(height: 12),
],
SectionCard(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
@ -86,7 +167,10 @@ class const BrowseTab({super.key}) extends StatelessWidget {
l10n.projectsTitle,
tip: l10n.projectAdd,
onAdd: () async {
final added = await editProject(context);
final added = await editProject(
context,
projects: state.projects,
);
if (added != null) {
await todo.projects.save(
added.copyWith(
@ -108,26 +192,15 @@ class const BrowseTab({super.key}) extends StatelessWidget {
),
),
),
for (final project in projects)
ListTile(
leading: Icon(
LucideIcons.hash,
color: switch (project.content!.color) {
final color? => paletteText(context, color),
null => null,
},
for (final node in tree)
if (!hidden.contains(node.project.id))
projectRow(
node.project,
depth: node.depth,
folded: node.hasChildren
? _folded.contains(node.project.id)
: null,
),
title: Text(project.content!.name),
trailing: switch (openCountIn(
state.tasks,
project.id,
)) {
0 => null,
final count => Text('$count'),
},
onTap: () =>
context.go(TodoPaths.project(project.id)),
),
],
),
),

View file

@ -13,6 +13,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
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';
@ -1966,10 +1967,11 @@ void main() {
await start(tester, usedBefore: true);
await goTab(tester, 'Browse');
await tester.tap(find.byTooltip('New project'));
await tester.tap(find.byTooltip('Add project'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Home');
await tester.tap(find.text('Save'));
await tester.enterText(find.byType(TextField).first, 'Home');
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
@ -2019,7 +2021,8 @@ void main() {
await tester.tap(find.text('Edit project'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Yard');
await tester.tap(find.text('Save'));
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(projects.all);
@ -2028,6 +2031,175 @@ void main() {
expect(yard?.view, const ProjectView(layout: ProjectLayout.board));
});
group("a project's editor as Todoist's Add project (D28)", () {
ProjectRepository projects() => ProjectRepository(projectRows, keys);
/// Seeds Home with its sub-project Garden, and Work.
Future<Map<String, String>> seedProjects(WidgetTester tester) async {
final ids = await tester.runAsync(() async {
final home = await projects().save(
const ProjectContent(name: 'Home', position: 1),
);
final garden = await projects().save(
ProjectContent(name: 'Garden', position: 2, parentId: home),
);
final work = await projects().save(
const ProjectContent(name: 'Work', position: 3),
);
return {'Home': home, 'Garden': garden, 'Work': work};
});
projectRows.bringRemoteChanges();
await tester.pumpAndSettle();
return ids!;
}
Future<StoredProject> stored(WidgetTester tester, String name) async =>
(await tester.runAsync(projects().all))!.singleWhere(
(p) => p.content?.name == name,
);
testWidgets('a full screen with its name counted to 120, description, '
'colour, parent, favourite and layout; Browse lists it under its '
'parent and among the Favourites', (tester) async {
await start(tester, usedBefore: true);
final ids = await seedProjects(tester);
await goTab(tester, 'Browse');
await tester.tap(find.byTooltip('Add project'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Add project'), findsOneWidget);
expect(find.text('0/120'), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.widgetWithIcon(IconButton, LucideIcons.check),
)
.onPressed,
isNull,
reason: 'no name yet',
);
await tester.enterText(find.byType(TextField).first, 'Roadmap');
await tester.enterText(find.byType(TextField).at(1), 'What comes');
await tester.pump();
expect(find.text('7/120'), findsOneWidget);
await tester.tap(find.text('Parent project'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Home').last);
await tester.pumpAndSettle();
expect(find.widgetWithText(ListTile, 'Parent project'), findsOneWidget);
// The test's language is American English.
await tester.tap(find.text('Favorite'));
await tester.ensureVisible(find.text('Board'));
await tester.pumpAndSettle();
await tester.tap(find.text('Board'));
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
final roadmap = (await stored(tester, 'Roadmap')).content!;
expect(roadmap.description, 'What comes');
expect(roadmap.parentId, ids['Home']);
expect(roadmap.favorite, isTrue);
expect(roadmap.view, const ProjectView(layout: ProjectLayout.board));
expect(roadmap.defaultView, roadmap.view);
expect(find.text('Favorites'), findsOneWidget);
expect(find.text('Roadmap'), findsNWidgets(2));
double startOf(String name) =>
tester.getTopLeft(find.text(name).last).dx;
expect(startOf('Garden'), greaterThan(startOf('Home')));
expect(startOf('Roadmap'), startOf('Garden'));
await tester.tap(find.byTooltip('Hide sub-projects'));
await tester.pumpAndSettle();
expect(find.text('Garden'), findsNothing);
expect(find.text('Roadmap'), findsOneWidget, reason: 'a favourite');
await tester.tap(find.byTooltip('Show sub-projects'));
await tester.pumpAndSettle();
expect(find.text('Garden'), findsOneWidget);
});
testWidgets('a project is never offered as its own parent, nor its '
'sub-projects', (tester) async {
await start(tester, usedBefore: true);
await seedProjects(tester);
await goTab(tester, 'Browse');
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit project'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Edit project'), findsOneWidget);
await tester.tap(find.text('Parent project'));
await tester.pumpAndSettle();
final sheet = find.byType(SheetScaffold);
Finder offered(String name) =>
find.descendant(of: sheet, matching: find.text(name));
expect(offered('No parent'), findsOneWidget);
expect(offered('Work'), findsOneWidget);
expect(offered('Home'), findsNothing);
expect(offered('Garden'), findsNothing);
});
testWidgets("Display's Reset returns to the layout the project was made "
'with', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => projects().save(
const ProjectContent(
name: 'Roadmap',
color: PaletteColor.fern,
view: ProjectView(layout: ProjectLayout.board, completed: true),
defaultView: ProjectView(layout: ProjectLayout.board),
),
),
);
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.text('Roadmap'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
final roadmap = (await stored(tester, 'Roadmap')).content!;
expect(roadmap.view, const ProjectView(layout: ProjectLayout.board));
expect(roadmap.color, PaletteColor.fern);
});
testWidgets('deleting a project names its sub-projects first and takes '
'them with it, as Todoist', (tester) async {
await start(tester, usedBefore: true);
final ids = await seedProjects(tester);
await seed(
tester,
TaskContent(title: 'Weed', projectId: ids['Garden']!),
);
await goTab(tester, 'Browse');
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete project'));
await tester.pumpAndSettle();
expect(find.textContaining('sub-projects Garden'), findsOneWidget);
await tester.tap(find.text('Delete project').last);
await tester.pumpAndSettle();
final left = await tester.runAsync(projects().all);
expect(
[
for (final p in left!)
if (!p.inbox) p.content?.name,
],
['Work'],
);
expect(await tester.runAsync(() => tasks().all()), isEmpty);
});
});
testWidgets("a project's screen washes in its own colour, as a "
"studyset's in Learn; one without keeps Todo's strawberry", (
tester,

View file

@ -107,4 +107,70 @@ void main() {
isNull,
);
});
group('since D28', () {
Map<String, Object?> written(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
test('its description, parent, favourite and default Display read back '
'as written', () {
const garden = ProjectContent(
name: 'Garden',
description: 'Beds and the shed',
parentId: 'home',
favorite: true,
view: ProjectView(layout: ProjectLayout.board, completed: true),
defaultView: ProjectView(layout: ProjectLayout.board),
);
expect(decodeProject(encodeProject(garden)), garden);
expect(written(encodeProject(garden)), {
'schemaVersion': 1,
'name': 'Garden',
'position': 0.0,
'view': {'layout': 'board', 'completed': true},
'description': 'Beds and the shed',
'parentId': 'home',
'favorite': true,
'defaultView': {'layout': 'board'},
});
});
test('a payload from before reads as a project of its own, no '
'favourite, its Display defaults as at first', () {
final older = decodeProject(
json({
'schemaVersion': 1,
'name': 'Home',
'position': 1,
'view': {'layout': 'calendar'},
}),
);
expect(older?.description, '');
expect(older?.parentId, isNull);
expect(older?.favorite, isFalse);
expect(older?.defaultView, const ProjectView());
expect(older?.unknown, isEmpty);
expect(older?.viewIsDefault, isFalse, reason: 'its Display was set');
});
test('what a newer version wrote that this one doesn’t know is kept, '
'and written back as it was, however the project changes here', () {
final newer = {
'schemaVersion': 1,
'name': 'Home',
'position': 0.0,
'icon': 'house',
'members': ['a', 'b'],
};
final read = decodeProject(json(newer))!;
expect(read.unknown, {
'icon': 'house',
'members': ['a', 'b'],
});
final renamed = read.copyWith(name: 'House');
expect(written(encodeProject(renamed)), {...newer, 'name': 'House'});
expect(decodeProject(encodeProject(renamed)), renamed);
});
});
}

View file

@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
/// Sub-projects (ADR 0033, D28): Browse lists each under its parent, and a
/// project is never moved under itself or a project below it.
void main() {
StoredProject project(String id, {String? parent}) =>
StoredProject(id, ProjectContent(name: id, parentId: parent));
final projects = [
const StoredProject('inbox', ProjectContent.inbox, inbox: true),
project('Home'),
project('Work'),
project('Garden', parent: 'Home'),
project('Beds', parent: 'Garden'),
project('Kitchen', parent: 'Home'),
const StoredProject('locked', null),
];
test('each project is followed by its sub-projects, a level deeper, in '
'their order; the Inbox and what does not open are left out', () {
expect(
[
for (final node in projectTree(projects))
(node.project.id, node.depth, node.hasChildren),
],
[
('Home', 0, true),
('Garden', 1, true),
('Beds', 2, false),
('Kitchen', 1, false),
('Work', 0, false),
],
);
});
test('a project whose parent is gone, or whose parents run in a circle, '
'stands as a project of its own, none lost', () {
final tree = projectTree([
project('Orphan', parent: 'deleted'),
project('A', parent: 'B'),
project('B', parent: 'A'),
]);
expect([for (final node in tree) node.project.id], ['Orphan', 'A', 'B']);
expect(tree.first.depth, 0);
});
test('a project is offered as a parent but for itself and its '
'sub-projects at every depth', () {
expect(descendantsOf(projects, 'Home'), {'Garden', 'Beds', 'Kitchen'});
expect(
[for (final choice in parentChoices(projects, 'Garden')) choice.id],
['Home', 'Kitchen', 'Work'],
);
expect(
[for (final choice in parentChoices(projects, 'Home')) choice.id],
['Work'],
);
expect(
[for (final choice in parentChoices(projects, null)) choice.id],
['Home', 'Garden', 'Beds', 'Kitchen', 'Work'],
);
});
}