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

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,