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
20 changed files with 1094 additions and 93 deletions
File diff suppressed because one or more lines are too long
|
|
@ -51,11 +51,18 @@
|
||||||
"priority4": "Keine Priorität",
|
"priority4": "Keine Priorität",
|
||||||
"projectsTitle": "Projekte",
|
"projectsTitle": "Projekte",
|
||||||
"projectsNone": "Noch keine eigenen Projekte.",
|
"projectsNone": "Noch keine eigenen Projekte.",
|
||||||
"projectAdd": "Neues Projekt",
|
"projectAdd": "Projekt hinzufügen",
|
||||||
"projectEdit": "Projekt bearbeiten",
|
"projectEdit": "Projekt bearbeiten",
|
||||||
"projectDelete": "Projekt löschen",
|
"projectDelete": "Projekt löschen",
|
||||||
"projectDeleteTitle": "Projekt löschen?",
|
"projectDeleteTitle": "Projekt löschen?",
|
||||||
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
|
"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",
|
"projectEmptyTitle": "Hier ist nichts zu tun",
|
||||||
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
|
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
|
||||||
"projectGone": "Dieses Projekt wurde gelöscht.",
|
"projectGone": "Dieses Projekt wurde gelöscht.",
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
"priority4": "No priority",
|
"priority4": "No priority",
|
||||||
"projectsTitle": "Projects",
|
"projectsTitle": "Projects",
|
||||||
"projectsNone": "No projects of your own yet.",
|
"projectsNone": "No projects of your own yet.",
|
||||||
"projectAdd": "New project",
|
"projectAdd": "Add project",
|
||||||
"projectEdit": "Edit project",
|
"projectEdit": "Edit project",
|
||||||
"projectDelete": "Delete project",
|
"projectDelete": "Delete project",
|
||||||
"projectDeleteTitle": "Delete the 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",
|
"projectEmptyTitle": "Nothing to do here",
|
||||||
"projectEmptyHint": "Add a task with the + button.",
|
"projectEmptyHint": "Add a task with the + button.",
|
||||||
"projectGone": "This project was deleted.",
|
"projectGone": "This project was deleted.",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "en_US"
|
"@@locale": "en_US",
|
||||||
|
"projectFavorite": "Favorite",
|
||||||
|
"favoritesTitle": "Favorites"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,7 +409,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @projectAdd.
|
/// No description provided for @projectAdd.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'New project'**
|
/// **'Add project'**
|
||||||
String get projectAdd;
|
String get projectAdd;
|
||||||
|
|
||||||
/// No description provided for @projectEdit.
|
/// No description provided for @projectEdit.
|
||||||
|
|
@ -436,6 +436,48 @@ abstract class AppLocalizations {
|
||||||
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
|
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
|
||||||
String projectDeleteMessage(String name);
|
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.
|
/// No description provided for @projectEmptyTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get projectsNone => 'Noch keine eigenen Projekte.';
|
String get projectsNone => 'Noch keine eigenen Projekte.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get projectAdd => 'Neues Projekt';
|
String get projectAdd => 'Projekt hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get projectEdit => 'Projekt bearbeiten';
|
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.';
|
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
|
@override
|
||||||
String get projectEmptyTitle => 'Hier ist nichts zu tun';
|
String get projectEmptyTitle => 'Hier ist nichts zu tun';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get projectsNone => 'No projects of your own yet.';
|
String get projectsNone => 'No projects of your own yet.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get projectAdd => 'New project';
|
String get projectAdd => 'Add project';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get projectEdit => 'Edit project';
|
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.';
|
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
|
@override
|
||||||
String get projectEmptyTitle => 'Nothing to do here';
|
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`).
|
/// The translations for English, as used in the United States (`en_US`).
|
||||||
class AppLocalizationsEnUs extends AppLocalizationsEn {
|
class AppLocalizationsEnUs extends AppLocalizationsEn {
|
||||||
AppLocalizationsEnUs() : super('en_US');
|
AppLocalizationsEnUs() : super('en_US');
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get projectFavorite => 'Favorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favoritesTitle => 'Favorites';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/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/project_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.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/reminder_gateway.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_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';
|
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||||
|
|
@ -62,14 +63,18 @@ class TodoPerson(
|
||||||
final ReminderGateway reminders = const NoReminders(),
|
final ReminderGateway reminders = const NoReminders(),
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) 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.
|
/// [InboxKept] for the Inbox, which keeps everything.
|
||||||
Future<void> deleteProject(String id) async {
|
Future<void> deleteProject(String id) async {
|
||||||
await projects.delete(id);
|
final ids = [id, ...descendantsOf(await projects.all(), id)];
|
||||||
await tasks.deleteIn(id);
|
for (final project in ids) {
|
||||||
|
await projects.delete(project);
|
||||||
|
await tasks.deleteIn(project);
|
||||||
|
}
|
||||||
await sections.delete([
|
await sections.delete([
|
||||||
for (final section in await sections.all())
|
for (final section in await sections.all())
|
||||||
if (section.content?.projectId == id) section.id,
|
if (ids.contains(section.content?.projectId)) section.id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_todo_flutter/src/feature/task/model/task.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.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;
|
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
|
/// [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) =>
|
ByteData encodeProject(ProjectContent content) =>
|
||||||
encodeJson(projectSchemaVersion, {
|
encodeJson(projectSchemaVersion, {
|
||||||
|
...content.unknown,
|
||||||
'name': content.name,
|
'name': content.name,
|
||||||
'color': ?content.color?.name,
|
'color': ?content.color?.name,
|
||||||
'position': content.position,
|
'position': content.position,
|
||||||
// As at first, nothing: a project's Display is written once set (D17).
|
// As at first, nothing: a project's Display is written once set (D17).
|
||||||
if (!content.view.isDefault) 'view': encodeView(content.view),
|
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.
|
/// [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
|
/// 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(
|
ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
||||||
payload,
|
payload,
|
||||||
version: projectSchemaVersion,
|
version: projectSchemaVersion,
|
||||||
|
|
@ -72,5 +98,13 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
||||||
color: PaletteColor.byName(json['color'] as String?),
|
color: PaletteColor.byName(json['color'] as String?),
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||||
view: decodeView(json['view']),
|
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,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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),
|
/// [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
|
/// its [position] among the person's projects, smaller first, and how it
|
||||||
/// shows its tasks ([view], D17). The Inbox
|
/// shows its tasks ([view], D17); since D28 its [description], the project
|
||||||
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
|
/// it is a sub-project of ([parentId]), whether it is a [favorite], and
|
||||||
/// shows under the app's own word for it, whatever [name] holds.
|
/// 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({
|
final class const ProjectContent({
|
||||||
required final String name,
|
required final String name,
|
||||||
final PaletteColor? color,
|
final PaletteColor? color,
|
||||||
|
|
@ -15,22 +17,65 @@ final class const ProjectContent({
|
||||||
|
|
||||||
/// How the project shows its tasks (D17).
|
/// How the project shows its tasks (D17).
|
||||||
final ProjectView view = const ProjectView(),
|
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 {
|
}) with Equatable {
|
||||||
/// The Inbox's content, as the first device writes it.
|
/// The Inbox's content, as the first device writes it.
|
||||||
static const inbox = ProjectContent(name: '');
|
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({
|
ProjectContent copyWith({
|
||||||
String? name,
|
String? name,
|
||||||
PaletteColor? Function()? color,
|
PaletteColor? Function()? color,
|
||||||
double? position,
|
double? position,
|
||||||
ProjectView? view,
|
ProjectView? view,
|
||||||
|
String? description,
|
||||||
|
String? Function()? parentId,
|
||||||
|
bool? favorite,
|
||||||
|
ProjectView? defaultView,
|
||||||
}) => ProjectContent(
|
}) => ProjectContent(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
color: color == null ? this.color : color(),
|
color: color == null ? this.color : color(),
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
view: view ?? this.view,
|
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
|
@override
|
||||||
List<Object?> get props => [name, color, position, view];
|
List<Object?> get props => [
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
position,
|
||||||
|
view,
|
||||||
|
description,
|
||||||
|
parentId,
|
||||||
|
favorite,
|
||||||
|
defaultView,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -69,25 +69,23 @@ DisplayTarget projectDisplay(String projectId) {
|
||||||
return DisplayTarget(
|
return DisplayTarget(
|
||||||
viewOf: viewOf,
|
viewOf: viewOf,
|
||||||
write: write,
|
write: write,
|
||||||
isDefault: (state) => viewOf(state).isDefault,
|
// Reset returns to the Display the project was made with (D28).
|
||||||
reset: (todo, state) => write(todo, state, const ProjectView()),
|
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),
|
grouping: (state) => !calendar(state),
|
||||||
occurrences: calendar,
|
occurrences: calendar,
|
||||||
layout: (context, state, apply) {
|
layout: (context, state, apply) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final view = viewOf(state);
|
final view = viewOf(state);
|
||||||
return [
|
return [
|
||||||
_LayoutPicker<ProjectLayout>(
|
LayoutPicker<ProjectLayout>(
|
||||||
selected: view.layout,
|
selected: view.layout,
|
||||||
segments: [
|
segments: projectLayouts(l10n),
|
||||||
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
|
|
||||||
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
|
|
||||||
(
|
|
||||||
ProjectLayout.calendar,
|
|
||||||
LucideIcons.calendarDays,
|
|
||||||
l10n.layoutCalendar,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onPicked: (layout) => apply(
|
onPicked: (layout) => apply(
|
||||||
(todo) => write(todo, state, view.copyWith(layout: layout)),
|
(todo) => write(todo, state, view.copyWith(layout: layout)),
|
||||||
),
|
),
|
||||||
|
|
@ -125,7 +123,7 @@ final upcomingDisplay = DisplayTarget(
|
||||||
(todo) => todo.settings.write(state.settings.copyWith(upcoming: view)),
|
(todo) => todo.settings.write(state.settings.copyWith(upcoming: view)),
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
_LayoutPicker<UpcomingLayout>(
|
LayoutPicker<UpcomingLayout>(
|
||||||
selected: upcoming.layout,
|
selected: upcoming.layout,
|
||||||
segments: [
|
segments: [
|
||||||
(UpcomingLayout.agenda, LucideIcons.list, l10n.layoutAgenda),
|
(UpcomingLayout.agenda, LucideIcons.list, l10n.layoutAgenda),
|
||||||
|
|
@ -257,8 +255,8 @@ class const _DisplaySheet({required final DisplayTarget target})
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
heading(l10n.layoutTitle),
|
heading(l10n.layoutTitle),
|
||||||
_Group(children: target.layout(context, state, apply)),
|
RoundedGroup(children: target.layout(context, state, apply)),
|
||||||
_Group(
|
RoundedGroup(
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(LucideIcons.circleCheck),
|
secondary: const Icon(LucideIcons.circleCheck),
|
||||||
|
|
@ -276,7 +274,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
heading(l10n.displaySort),
|
heading(l10n.displaySort),
|
||||||
_Group(
|
RoundedGroup(
|
||||||
children: [
|
children: [
|
||||||
if (target.grouping(state))
|
if (target.grouping(state))
|
||||||
choice(
|
choice(
|
||||||
|
|
@ -316,7 +314,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
heading(l10n.displayFilter),
|
heading(l10n.displayFilter),
|
||||||
_Group(
|
RoundedGroup(
|
||||||
children: [
|
children: [
|
||||||
if (target.dateFilter)
|
if (target.dateFilter)
|
||||||
choice(
|
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,
|
/// 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;
|
/// its name below on one line, and a radio button marking the one in use;
|
||||||
/// the picture, the name or the button picks it.
|
/// 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 T selected,
|
||||||
required final List<(T, IconData, String)> segments,
|
required final List<(T, IconData, String)> segments,
|
||||||
required final ValueChanged<T> onPicked,
|
required final ValueChanged<T> onPicked,
|
||||||
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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
|
/// [children] in a box rounded at its ends, as Todoist's Display groups
|
||||||
/// its rows (the developer's review, 3 Nov 2026).
|
/// its rows (the developer's review, 3 Nov 2026), and a project's editor
|
||||||
class const _Group({required final List<Widget> children})
|
/// its own (D28).
|
||||||
|
class const RoundedGroup({required final List<Widget> children, super.key})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,9 @@
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
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/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_todo_flutter/src/feature/section/model/section.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_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
|
/// Asks for a label's name and colour, [current]'s to change, or a new
|
||||||
/// one's; null where the person changed their mind.
|
/// one's; null where the person changed their mind.
|
||||||
Future<LabelContent?> editLabel(
|
Future<LabelContent?> editLabel(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/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/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/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/bloc/todo_cubit.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.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(
|
PopupMenuItem(
|
||||||
value: () async {
|
value: () async {
|
||||||
final todo = PersonScope.of<TodoPerson>(context);
|
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?) {
|
case final edited?) {
|
||||||
await todo.projects.update(projectId, edited);
|
await todo.projects.update(projectId, edited);
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +130,22 @@ class const ProjectScreen({required final String projectId, super.key})
|
||||||
if (await confirmDestructiveAction(
|
if (await confirmDestructiveAction(
|
||||||
context,
|
context,
|
||||||
title: l10n.projectDeleteTitle,
|
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,
|
confirmLabel: l10n.projectDelete,
|
||||||
)) {
|
)) {
|
||||||
await todo.deleteProject(projectId);
|
await todo.deleteProject(projectId);
|
||||||
|
|
|
||||||
|
|
@ -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/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/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_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/bloc/todo_cubit.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.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';
|
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
|
/// person's projects, each in its colour with
|
||||||
/// how many of its tasks are open, one opening its screen, and one more to
|
/// 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
|
/// add; their labels, to add, rename or delete; the cog to Settings. What
|
||||||
/// doesn't open on this device is counted at the foot.
|
/// doesn't open on this device is counted at the foot. Since D28, as
|
||||||
class const BrowseTab({super.key}) extends StatelessWidget {
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -47,6 +61,53 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
||||||
for (final project in state.projects)
|
for (final project in state.projects)
|
||||||
if (!project.inbox && project.content != null) project,
|
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(
|
return TabScreen(
|
||||||
title: l10n.shellTabBrowse,
|
title: l10n.shellTabBrowse,
|
||||||
actions: const [SettingsButton()],
|
actions: const [SettingsButton()],
|
||||||
|
|
@ -77,6 +138,26 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
SectionCard(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -86,7 +167,10 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
||||||
l10n.projectsTitle,
|
l10n.projectsTitle,
|
||||||
tip: l10n.projectAdd,
|
tip: l10n.projectAdd,
|
||||||
onAdd: () async {
|
onAdd: () async {
|
||||||
final added = await editProject(context);
|
final added = await editProject(
|
||||||
|
context,
|
||||||
|
projects: state.projects,
|
||||||
|
);
|
||||||
if (added != null) {
|
if (added != null) {
|
||||||
await todo.projects.save(
|
await todo.projects.save(
|
||||||
added.copyWith(
|
added.copyWith(
|
||||||
|
|
@ -108,26 +192,15 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final project in projects)
|
for (final node in tree)
|
||||||
ListTile(
|
if (!hidden.contains(node.project.id))
|
||||||
leading: Icon(
|
projectRow(
|
||||||
LucideIcons.hash,
|
node.project,
|
||||||
color: switch (project.content!.color) {
|
depth: node.depth,
|
||||||
final color? => paletteText(context, color),
|
folded: node.hasChildren
|
||||||
null => null,
|
? _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)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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/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.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.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/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_board.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.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 start(tester, usedBefore: true);
|
||||||
await goTab(tester, 'Browse');
|
await goTab(tester, 'Browse');
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New project'));
|
await tester.tap(find.byTooltip('Add project'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.enterText(find.byType(TextField), 'Home');
|
await tester.enterText(find.byType(TextField).first, 'Home');
|
||||||
await tester.tap(find.text('Save'));
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Home'), findsOneWidget);
|
expect(find.text('Home'), findsOneWidget);
|
||||||
|
|
||||||
|
|
@ -2019,7 +2021,8 @@ void main() {
|
||||||
await tester.tap(find.text('Edit project'));
|
await tester.tap(find.text('Edit project'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.enterText(find.byType(TextField).first, 'Yard');
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final stored = await tester.runAsync(projects.all);
|
final stored = await tester.runAsync(projects.all);
|
||||||
|
|
@ -2028,6 +2031,175 @@ void main() {
|
||||||
expect(yard?.view, const ProjectView(layout: ProjectLayout.board));
|
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 "
|
testWidgets("a project's screen washes in its own colour, as a "
|
||||||
"studyset's in Learn; one without keeps Todo's strawberry", (
|
"studyset's in Learn; one without keeps Todo's strawberry", (
|
||||||
tester,
|
tester,
|
||||||
|
|
|
||||||
|
|
@ -107,4 +107,70 @@ void main() {
|
||||||
isNull,
|
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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -461,6 +461,32 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
|
||||||
- **D27 Listed in F-Droid as *Task* and *Calendar & Agenda*** (the developer's, both,
|
- **D27 Listed in F-Droid as *Task* and *Calendar & Agenda*** (the developer's, both,
|
||||||
over *Time*, which F-Droid's categories give to clocks and timers): no other app of
|
over *Time*, which F-Droid's categories give to clocks and timers): no other app of
|
||||||
the repository used *Time*, so the repository no longer lists it.
|
the repository used *Time*, so the repository no longer lists it.
|
||||||
|
- **D28 A project's editor as Todoist's Add project, with sub-projects and
|
||||||
|
favourites** (the developer's, over the dialog of a name and a colour): a screen of its
|
||||||
|
own on a phone (*Add project*, *Edit project*, ← and ✓ in its app bar), the same in a
|
||||||
|
dialog where sheets are dialogs; the name counted to 120, a description, then rounded
|
||||||
|
rows: the colour (its palette name, opening the picker), the parent project (*No
|
||||||
|
parent* or a project, from a sheet listing them indented; never the project itself
|
||||||
|
nor any project below it, `parentChoices`, so none becomes its own ancestor), a
|
||||||
|
Favourite switch; then the Layout, Display's pictures (`LayoutPicker`). All of it is
|
||||||
|
**in the project's sealed payload** (`description`, `parentId`, `favorite`,
|
||||||
|
`defaultView`), no server model changed, no migration, the schema still 1; the payload
|
||||||
|
now keeps the fields it doesn't know, as a task's (D18), so a later version's survive
|
||||||
|
an edit here (a 0.2.0 device editing a project still drops these). **The default
|
||||||
|
Display**: a new project starts with the layout picked, and Display's *Reset all*
|
||||||
|
returns to it (`defaultView`, the dot on Display marking a Display that differs from
|
||||||
|
it); the editor sets its layout, and a layout picked anew there shows at once.
|
||||||
|
Projects made before keep List as their default. **Browse** lists each project's
|
||||||
|
sub-projects indented under it, folding under its chevron (the fold not kept, agent's
|
||||||
|
choice: a choice of the device, and quick to make again), and the favourite projects
|
||||||
|
in a *Favourites* group above the projects. A project whose parent is gone or doesn't
|
||||||
|
open here, or projects whose parents run in a circle (two devices each moving one
|
||||||
|
under the other), stand as projects of their own, none lost (`projectTree`). **Deleting
|
||||||
|
a project deletes its sub-projects**, with their sections and tasks, as Todoist does,
|
||||||
|
the confirmation naming them first (`TodoPerson.deleteProject`). *Alternatives:* the
|
||||||
|
sub-projects moved up a level (kept, but surprising after a confirmation that named
|
||||||
|
only the parent); a depth limit as Todoist's (four levels), left until deep trees turn
|
||||||
|
out to be a problem.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue