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
19 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",
|
||||
"projectsTitle": "Projekte",
|
||||
"projectsNone": "Noch keine eigenen Projekte.",
|
||||
"projectAdd": "Neues Projekt",
|
||||
"projectAdd": "Projekt hinzufügen",
|
||||
"projectEdit": "Projekt bearbeiten",
|
||||
"projectDelete": "Projekt löschen",
|
||||
"projectDeleteTitle": "Projekt löschen?",
|
||||
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
|
||||
"projectParent": "Übergeordnetes Projekt",
|
||||
"projectNoParent": "Kein übergeordnetes Projekt",
|
||||
"projectFavorite": "Favorit",
|
||||
"favoritesTitle": "Favoriten",
|
||||
"projectSubProjectsHide": "Unterprojekte ausblenden",
|
||||
"projectSubProjectsShow": "Unterprojekte einblenden",
|
||||
"projectDeleteWithSubProjects": "„{name}“ wird mit seinen Unterprojekten {subProjects} und ihren Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
|
||||
"projectEmptyTitle": "Hier ist nichts zu tun",
|
||||
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
|
||||
"projectGone": "Dieses Projekt wurde gelöscht.",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
"priority4": "No priority",
|
||||
"projectsTitle": "Projects",
|
||||
"projectsNone": "No projects of your own yet.",
|
||||
"projectAdd": "New project",
|
||||
"projectAdd": "Add project",
|
||||
"projectEdit": "Edit project",
|
||||
"projectDelete": "Delete project",
|
||||
"projectDeleteTitle": "Delete the project?",
|
||||
|
|
@ -118,6 +118,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"projectParent": "Parent project",
|
||||
"@projectParent": {
|
||||
"description": "A project's editor (ADR 0033, D28), as Todoist's Add project."
|
||||
},
|
||||
"projectNoParent": "No parent",
|
||||
"projectFavorite": "Favourite",
|
||||
"favoritesTitle": "Favourites",
|
||||
"projectSubProjectsHide": "Hide sub-projects",
|
||||
"projectSubProjectsShow": "Show sub-projects",
|
||||
"projectDeleteWithSubProjects": "\"{name}\" is deleted with its sub-projects {subProjects}, and their sections and tasks, on all your devices.",
|
||||
"@projectDeleteWithSubProjects": {
|
||||
"description": "Deleting a project with sub-projects (ADR 0033, D28): subProjects their names, separated by commas.",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"subProjects": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"projectEmptyTitle": "Nothing to do here",
|
||||
"projectEmptyHint": "Add a task with the + button.",
|
||||
"projectGone": "This project was deleted.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New project'**
|
||||
/// **'Add project'**
|
||||
String get projectAdd;
|
||||
|
||||
/// No description provided for @projectEdit.
|
||||
|
|
@ -436,6 +436,48 @@ abstract class AppLocalizations {
|
|||
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
|
||||
String projectDeleteMessage(String name);
|
||||
|
||||
/// A project's editor (ADR 0033, D28), as Todoist's Add project.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Parent project'**
|
||||
String get projectParent;
|
||||
|
||||
/// No description provided for @projectNoParent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No parent'**
|
||||
String get projectNoParent;
|
||||
|
||||
/// No description provided for @projectFavorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favourite'**
|
||||
String get projectFavorite;
|
||||
|
||||
/// No description provided for @favoritesTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favourites'**
|
||||
String get favoritesTitle;
|
||||
|
||||
/// No description provided for @projectSubProjectsHide.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide sub-projects'**
|
||||
String get projectSubProjectsHide;
|
||||
|
||||
/// No description provided for @projectSubProjectsShow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show sub-projects'**
|
||||
String get projectSubProjectsShow;
|
||||
|
||||
/// Deleting a project with sub-projects (ADR 0033, D28): subProjects their names, separated by commas.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{name}\" is deleted with its sub-projects {subProjects}, and their sections and tasks, on all your devices.'**
|
||||
String projectDeleteWithSubProjects(String name, String subProjects);
|
||||
|
||||
/// No description provided for @projectEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get projectsNone => 'Noch keine eigenen Projekte.';
|
||||
|
||||
@override
|
||||
String get projectAdd => 'Neues Projekt';
|
||||
String get projectAdd => 'Projekt hinzufügen';
|
||||
|
||||
@override
|
||||
String get projectEdit => 'Projekt bearbeiten';
|
||||
|
|
@ -207,6 +207,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get projectParent => 'Übergeordnetes Projekt';
|
||||
|
||||
@override
|
||||
String get projectNoParent => 'Kein übergeordnetes Projekt';
|
||||
|
||||
@override
|
||||
String get projectFavorite => 'Favorit';
|
||||
|
||||
@override
|
||||
String get favoritesTitle => 'Favoriten';
|
||||
|
||||
@override
|
||||
String get projectSubProjectsHide => 'Unterprojekte ausblenden';
|
||||
|
||||
@override
|
||||
String get projectSubProjectsShow => 'Unterprojekte einblenden';
|
||||
|
||||
@override
|
||||
String projectDeleteWithSubProjects(String name, String subProjects) {
|
||||
return '„$name“ wird mit seinen Unterprojekten $subProjects und ihren Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get projectEmptyTitle => 'Hier ist nichts zu tun';
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get projectsNone => 'No projects of your own yet.';
|
||||
|
||||
@override
|
||||
String get projectAdd => 'New project';
|
||||
String get projectAdd => 'Add project';
|
||||
|
||||
@override
|
||||
String get projectEdit => 'Edit project';
|
||||
|
|
@ -207,6 +207,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '\"$name\" is deleted with its sections and tasks, on all your devices.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get projectParent => 'Parent project';
|
||||
|
||||
@override
|
||||
String get projectNoParent => 'No parent';
|
||||
|
||||
@override
|
||||
String get projectFavorite => 'Favourite';
|
||||
|
||||
@override
|
||||
String get favoritesTitle => 'Favourites';
|
||||
|
||||
@override
|
||||
String get projectSubProjectsHide => 'Hide sub-projects';
|
||||
|
||||
@override
|
||||
String get projectSubProjectsShow => 'Show sub-projects';
|
||||
|
||||
@override
|
||||
String projectDeleteWithSubProjects(String name, String subProjects) {
|
||||
return '\"$name\" is deleted with its sub-projects $subProjects, and their sections and tasks, on all your devices.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get projectEmptyTitle => 'Nothing to do here';
|
||||
|
||||
|
|
@ -995,4 +1018,10 @@ class AppLocalizationsEnGb extends AppLocalizationsEn {
|
|||
/// The translations for English, as used in the United States (`en_US`).
|
||||
class AppLocalizationsEnUs extends AppLocalizationsEn {
|
||||
AppLocalizationsEnUs() : super('en_US');
|
||||
|
||||
@override
|
||||
String get projectFavorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get favoritesTitle => 'Favorites';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
|
|
@ -62,14 +63,18 @@ class TodoPerson(
|
|||
final ReminderGateway reminders = const NoReminders(),
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// Deletes the project [id] with its sections and tasks; throws
|
||||
/// Deletes the project [id] with its sections and tasks, and its
|
||||
/// sub-projects with theirs, as Todoist does (ADR 0033, D28); throws
|
||||
/// [InboxKept] for the Inbox, which keeps everything.
|
||||
Future<void> deleteProject(String id) async {
|
||||
await projects.delete(id);
|
||||
await tasks.deleteIn(id);
|
||||
final ids = [id, ...descendantsOf(await projects.all(), id)];
|
||||
for (final project in ids) {
|
||||
await projects.delete(project);
|
||||
await tasks.deleteIn(project);
|
||||
}
|
||||
await sections.delete([
|
||||
for (final section in await sections.all())
|
||||
if (section.content?.projectId == id) section.id,
|
||||
if (ids.contains(section.content?.projectId)) section.id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,18 +6,42 @@ import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// The schema of a project's payload this app writes and reads.
|
||||
/// The schema of a project's payload this app writes and reads. It stays 1
|
||||
/// as fields are added (D17, D28): an older version reads a project with
|
||||
/// them, and keeps them as it writes the project once it knows to
|
||||
/// ([ProjectContent.unknown], since D28).
|
||||
const projectSchemaVersion = 1;
|
||||
|
||||
/// The fields of a project's payload this version reads; any other is
|
||||
/// kept as it was (D28).
|
||||
const _known = {
|
||||
'schemaVersion',
|
||||
'name',
|
||||
'color',
|
||||
'position',
|
||||
'view',
|
||||
'description',
|
||||
'parentId',
|
||||
'favorite',
|
||||
'defaultView',
|
||||
};
|
||||
|
||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
|
||||
/// its palette name.
|
||||
/// its palette name; what a newer version wrote and this one doesn't know
|
||||
/// as it was, beneath what this one writes.
|
||||
ByteData encodeProject(ProjectContent content) =>
|
||||
encodeJson(projectSchemaVersion, {
|
||||
...content.unknown,
|
||||
'name': content.name,
|
||||
'color': ?content.color?.name,
|
||||
'position': content.position,
|
||||
// As at first, nothing: a project's Display is written once set (D17).
|
||||
if (!content.view.isDefault) 'view': encodeView(content.view),
|
||||
if (content.description.isNotEmpty) 'description': content.description,
|
||||
'parentId': ?content.parentId,
|
||||
if (content.favorite) 'favorite': true,
|
||||
if (!content.defaultView.isDefault)
|
||||
'defaultView': encodeView(content.defaultView),
|
||||
});
|
||||
|
||||
/// [view] by its values' names, those left as at first left out.
|
||||
|
|
@ -62,7 +86,9 @@ ProjectView decodeView(Object? json) {
|
|||
}
|
||||
|
||||
/// The project [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read. A colour it doesn't know is none.
|
||||
/// it can't read. A colour it doesn't know is none; a payload of a version
|
||||
/// before D28 is a project of its own, no favourite, without a
|
||||
/// description, its Display's defaults as at first.
|
||||
ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
||||
payload,
|
||||
version: projectSchemaVersion,
|
||||
|
|
@ -72,5 +98,13 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
|||
color: PaletteColor.byName(json['color'] as String?),
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
view: decodeView(json['view']),
|
||||
description: json['description'] as String? ?? '',
|
||||
parentId: json['parentId'] as String?,
|
||||
favorite: json['favorite'] == true,
|
||||
defaultView: decodeView(json['defaultView']),
|
||||
unknown: {
|
||||
for (final MapEntry(:key, :value) in json.entries)
|
||||
if (!_known.contains(key)) key: value,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'package:halkyon_ui/halkyon_ui.dart';
|
|||
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
|
||||
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
|
||||
/// its [position] among the person's projects, smaller first, and how it
|
||||
/// shows its tasks ([view], D17). The Inbox
|
||||
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
|
||||
/// shows under the app's own word for it, whatever [name] holds.
|
||||
/// shows its tasks ([view], D17); since D28 its [description], the project
|
||||
/// it is a sub-project of ([parentId]), whether it is a [favorite], and
|
||||
/// the Display its Reset returns to ([defaultView]). The Inbox is told by
|
||||
/// its row's ID, not by anything here (`ProjectRepository`), and shows
|
||||
/// under the app's own word for it, whatever [name] holds.
|
||||
final class const ProjectContent({
|
||||
required final String name,
|
||||
final PaletteColor? color,
|
||||
|
|
@ -15,22 +17,65 @@ final class const ProjectContent({
|
|||
|
||||
/// How the project shows its tasks (D17).
|
||||
final ProjectView view = const ProjectView(),
|
||||
|
||||
/// What the project is for, below its name in its editor (D28).
|
||||
final String description = '',
|
||||
|
||||
/// The project it is a sub-project of (D28); none: a project of its own.
|
||||
final String? parentId,
|
||||
|
||||
/// Whether Browse lists it among the Favorites too (D28).
|
||||
final bool favorite = false,
|
||||
|
||||
/// The Display it is made with and its Reset returns to (D28): its
|
||||
/// layout as its editor picks it, the rest as at first.
|
||||
final ProjectView defaultView = const ProjectView(),
|
||||
|
||||
/// What its payload holds that this version doesn't know (as a task's,
|
||||
/// D18): written back as it was, so that a project changed here keeps
|
||||
/// what a newer version gave it.
|
||||
final Map<String, Object?> unknown = const {},
|
||||
}) with Equatable {
|
||||
/// The Inbox's content, as the first device writes it.
|
||||
static const inbox = ProjectContent(name: '');
|
||||
|
||||
/// The longest name its editor takes (D28, as Todoist's).
|
||||
static const maxNameLength = 120;
|
||||
|
||||
/// Whether its Display is the one it was made with (D28).
|
||||
bool get viewIsDefault => view == defaultView;
|
||||
|
||||
ProjectContent copyWith({
|
||||
String? name,
|
||||
PaletteColor? Function()? color,
|
||||
double? position,
|
||||
ProjectView? view,
|
||||
String? description,
|
||||
String? Function()? parentId,
|
||||
bool? favorite,
|
||||
ProjectView? defaultView,
|
||||
}) => ProjectContent(
|
||||
name: name ?? this.name,
|
||||
color: color == null ? this.color : color(),
|
||||
position: position ?? this.position,
|
||||
view: view ?? this.view,
|
||||
description: description ?? this.description,
|
||||
parentId: parentId == null ? this.parentId : parentId(),
|
||||
favorite: favorite ?? this.favorite,
|
||||
defaultView: defaultView ?? this.defaultView,
|
||||
unknown: unknown,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, color, position, view];
|
||||
List<Object?> get props => [
|
||||
name,
|
||||
color,
|
||||
position,
|
||||
view,
|
||||
description,
|
||||
parentId,
|
||||
favorite,
|
||||
defaultView,
|
||||
unknown,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
viewOf: viewOf,
|
||||
write: write,
|
||||
isDefault: (state) => viewOf(state).isDefault,
|
||||
reset: (todo, state) => write(todo, state, const ProjectView()),
|
||||
// Reset returns to the Display the project was made with (D28).
|
||||
isDefault: (state) =>
|
||||
state.projectOf(projectId)?.content?.viewIsDefault ?? true,
|
||||
reset: (todo, state) => write(
|
||||
todo,
|
||||
state,
|
||||
state.projectOf(projectId)?.content?.defaultView ?? const ProjectView(),
|
||||
),
|
||||
grouping: (state) => !calendar(state),
|
||||
occurrences: calendar,
|
||||
layout: (context, state, apply) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final view = viewOf(state);
|
||||
return [
|
||||
_LayoutPicker<ProjectLayout>(
|
||||
LayoutPicker<ProjectLayout>(
|
||||
selected: view.layout,
|
||||
segments: [
|
||||
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
|
||||
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
|
||||
(
|
||||
ProjectLayout.calendar,
|
||||
LucideIcons.calendarDays,
|
||||
l10n.layoutCalendar,
|
||||
),
|
||||
],
|
||||
segments: projectLayouts(l10n),
|
||||
onPicked: (layout) => apply(
|
||||
(todo) => write(todo, state, view.copyWith(layout: layout)),
|
||||
),
|
||||
|
|
@ -125,7 +123,7 @@ final upcomingDisplay = DisplayTarget(
|
|||
(todo) => todo.settings.write(state.settings.copyWith(upcoming: view)),
|
||||
);
|
||||
return [
|
||||
_LayoutPicker<UpcomingLayout>(
|
||||
LayoutPicker<UpcomingLayout>(
|
||||
selected: upcoming.layout,
|
||||
segments: [
|
||||
(UpcomingLayout.agenda, LucideIcons.list, l10n.layoutAgenda),
|
||||
|
|
@ -257,8 +255,8 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
heading(l10n.layoutTitle),
|
||||
_Group(children: target.layout(context, state, apply)),
|
||||
_Group(
|
||||
RoundedGroup(children: target.layout(context, state, apply)),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
|
|
@ -276,7 +274,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
],
|
||||
),
|
||||
heading(l10n.displaySort),
|
||||
_Group(
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.grouping(state))
|
||||
choice(
|
||||
|
|
@ -316,7 +314,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
],
|
||||
),
|
||||
heading(l10n.displayFilter),
|
||||
_Group(
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.dateFilter)
|
||||
choice(
|
||||
|
|
@ -412,10 +410,22 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
/// 3 Nov 2026): each a phone's outline with its layout's picture in it,
|
||||
/// its name below on one line, and a radio button marking the one in use;
|
||||
/// the picture, the name or the button picks it.
|
||||
class const _LayoutPicker<T>({
|
||||
/// A project's layouts as a [LayoutPicker] offers them: List, Board,
|
||||
/// Calendar, each with its picture.
|
||||
List<(ProjectLayout, IconData, String)> projectLayouts(AppLocalizations l10n) =>
|
||||
[
|
||||
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
|
||||
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
|
||||
(ProjectLayout.calendar, LucideIcons.calendarDays, l10n.layoutCalendar),
|
||||
];
|
||||
|
||||
/// Layouts side by side, as Todoist's (D22): each a phone's outline with its
|
||||
/// picture, its name below, a radio button marking the [selected] one.
|
||||
class const LayoutPicker<T>({
|
||||
required final T selected,
|
||||
required final List<(T, IconData, String)> segments,
|
||||
required final ValueChanged<T> onPicked,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -481,8 +491,9 @@ class const _LayoutPicker<T>({
|
|||
}
|
||||
|
||||
/// [children] in a box rounded at its ends, as Todoist's Display groups
|
||||
/// its rows (the developer's review, 3 Nov 2026).
|
||||
class const _Group({required final List<Widget> children})
|
||||
/// its rows (the developer's review, 3 Nov 2026), and a project's editor
|
||||
/// its own (D28).
|
||||
class const RoundedGroup({required final List<Widget> children, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
|
|
|
|||
|
|
@ -1,35 +1,9 @@
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Asks for a project's name and colour (ADR 0033, D9), [current]'s to
|
||||
/// change, or a new one's; null where the person changed their mind.
|
||||
Future<ProjectContent?> editProject(
|
||||
BuildContext context, {
|
||||
ProjectContent? current,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final named = await _nameAndColor(
|
||||
context,
|
||||
title: current == null ? l10n.projectAdd : l10n.projectEdit,
|
||||
name: current?.name ?? '',
|
||||
color: current?.color ?? PaletteColor.cobalt,
|
||||
);
|
||||
return switch (named) {
|
||||
// The rest kept as it is: building it anew had reset the project's
|
||||
// Display (the developer's review, 4 Nov 2026).
|
||||
(:final name, :final color) =>
|
||||
(current ?? ProjectContent(name: name)).copyWith(
|
||||
name: name,
|
||||
color: () => color,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Asks for a label's name and colour, [current]'s to change, or a new
|
||||
/// one's; null where the person changed their mind.
|
||||
Future<LabelContent?> editLabel(
|
||||
|
|
|
|||
|
|
@ -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/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
|
|
@ -110,7 +111,12 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
PopupMenuItem(
|
||||
value: () async {
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
if (await editProject(context, current: content)
|
||||
if (await editProject(
|
||||
context,
|
||||
projects: state.projects,
|
||||
id: projectId,
|
||||
current: content,
|
||||
)
|
||||
case final edited?) {
|
||||
await todo.projects.update(projectId, edited);
|
||||
}
|
||||
|
|
@ -124,7 +130,22 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.projectDeleteTitle,
|
||||
message: l10n.projectDeleteMessage(content.name),
|
||||
// Its sub-projects go with it, as Todoist's (D28),
|
||||
// named here first.
|
||||
message: switch ([
|
||||
for (final node in projectTree(state.projects))
|
||||
if (descendantsOf(
|
||||
state.projects,
|
||||
projectId,
|
||||
).contains(node.project.id))
|
||||
node.project.content!.name,
|
||||
]) {
|
||||
[] => l10n.projectDeleteMessage(content.name),
|
||||
final names => l10n.projectDeleteWithSubProjects(
|
||||
content.name,
|
||||
names.join(', '),
|
||||
),
|
||||
},
|
||||
confirmLabel: l10n.projectDelete,
|
||||
)) {
|
||||
await todo.deleteProject(projectId);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
|
|
@ -19,8 +22,19 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// person's projects, each in its colour with
|
||||
/// how many of its tasks are open, one opening its screen, and one more to
|
||||
/// add; their labels, to add, rename or delete; the cog to Settings. What
|
||||
/// doesn't open on this device is counted at the foot.
|
||||
class const BrowseTab({super.key}) extends StatelessWidget {
|
||||
/// doesn't open on this device is counted at the foot. Since D28, as
|
||||
/// Todoist's: the favourite projects in a group of their own above, and
|
||||
/// each project's sub-projects indented under it, folding away under its
|
||||
/// chevron (not kept: each start shows them all).
|
||||
class const BrowseTab({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<BrowseTab> createState() => _BrowseTabState();
|
||||
}
|
||||
|
||||
class _BrowseTabState() extends State<BrowseTab> {
|
||||
/// The projects whose sub-projects are folded away.
|
||||
final _folded = <String>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -47,6 +61,53 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
|||
for (final project in state.projects)
|
||||
if (!project.inbox && project.content != null) project,
|
||||
];
|
||||
final tree = projectTree(state.projects);
|
||||
final favorites = [
|
||||
for (final project in projects)
|
||||
if (project.content!.favorite) project,
|
||||
];
|
||||
// Hidden: those under a folded project, however deep.
|
||||
final hidden = {
|
||||
for (final id in _folded) ...descendantsOf(state.projects, id),
|
||||
};
|
||||
Widget projectRow(StoredProject project, {int depth = 0, bool? folded}) =>
|
||||
ListTile(
|
||||
contentPadding: EdgeInsetsDirectional.only(
|
||||
start: 16 + 24.0 * depth,
|
||||
end: folded == null ? 16 : 4,
|
||||
),
|
||||
leading: Icon(
|
||||
LucideIcons.hash,
|
||||
color: switch (project.content!.color) {
|
||||
final color? => paletteText(context, color),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
title: Text(project.content!.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (openCountIn(state.tasks, project.id) case final count
|
||||
when count > 0)
|
||||
Text('$count'),
|
||||
if (folded != null)
|
||||
IconButton(
|
||||
tooltip: folded
|
||||
? l10n.projectSubProjectsShow
|
||||
: l10n.projectSubProjectsHide,
|
||||
icon: Icon(
|
||||
folded ? LucideIcons.chevronRight : LucideIcons.chevronDown,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => folded
|
||||
? _folded.remove(project.id)
|
||||
: _folded.add(project.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.go(TodoPaths.project(project.id)),
|
||||
);
|
||||
return TabScreen(
|
||||
title: l10n.shellTabBrowse,
|
||||
actions: const [SettingsButton()],
|
||||
|
|
@ -77,6 +138,26 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (favorites.isNotEmpty) ...[
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
l10n.favoritesTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
for (final project in favorites)
|
||||
projectRow(project),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
|
|
@ -86,7 +167,10 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
|||
l10n.projectsTitle,
|
||||
tip: l10n.projectAdd,
|
||||
onAdd: () async {
|
||||
final added = await editProject(context);
|
||||
final added = await editProject(
|
||||
context,
|
||||
projects: state.projects,
|
||||
);
|
||||
if (added != null) {
|
||||
await todo.projects.save(
|
||||
added.copyWith(
|
||||
|
|
@ -108,26 +192,15 @@ class const BrowseTab({super.key}) extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
for (final project in projects)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.hash,
|
||||
color: switch (project.content!.color) {
|
||||
final color? => paletteText(context, color),
|
||||
null => null,
|
||||
},
|
||||
for (final node in tree)
|
||||
if (!hidden.contains(node.project.id))
|
||||
projectRow(
|
||||
node.project,
|
||||
depth: node.depth,
|
||||
folded: node.hasChildren
|
||||
? _folded.contains(node.project.id)
|
||||
: null,
|
||||
),
|
||||
title: Text(project.content!.name),
|
||||
trailing: switch (openCountIn(
|
||||
state.tasks,
|
||||
project.id,
|
||||
)) {
|
||||
0 => null,
|
||||
final count => Text('$count'),
|
||||
},
|
||||
onTap: () =>
|
||||
context.go(TodoPaths.project(project.id)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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