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
2 changed files with 1094 additions and 93 deletions

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'],
);
});
}