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:
parent
7e2f224c66
commit
d0e72cf095
3 changed files with 1094 additions and 93 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue