feat(todo): the open sidebar lists favourites and the project tree, folds kept
Todo's open sidebar is laid out as Todoist's (the developer's decisions of 10 Nov 2026): the favourite projects, filters and labels, then the projects as a tree with their open counts, sub-projects folding. Browse and the sidebar share their rows (project_lists.dart) and their folds, which the device now keeps per person (FoldedProjects, folded_projects.json in the person's folder) instead of forgetting them at each start. Whats-New: On a wide screen, the open sidebar lists your favourites and projects; folded sub-projects stay folded. Whats-New-De: Auf breiten Bildschirmen zeigt die geöffnete Seitenleiste deine Favoriten und Projekte; eingeklappte Unterprojekte bleiben eingeklappt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3f441cbc98
commit
a37ca123b1
10 changed files with 413 additions and 94 deletions
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.d
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/map_tiles.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/folded_projects.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';
|
||||
|
|
@ -97,6 +98,10 @@ class TodoPerson(
|
|||
/// given; else for this session.
|
||||
final PaneWidths? _paneWidths,
|
||||
|
||||
/// The projects whose sub-projects are folded away (decided 10 Nov
|
||||
/// 2026): the person's folder's, where it is given; else for this session.
|
||||
final FoldedProjects? _foldedProjects,
|
||||
|
||||
/// Where the calendars linked to their projects are fetched from, by the
|
||||
/// device itself (D39); none in tests that don't say.
|
||||
final CalendarFeedGateway calendarFeeds = const NoCalendarFeeds(),
|
||||
|
|
@ -113,6 +118,10 @@ class TodoPerson(
|
|||
/// How wide the device keeps its side panes (ADR 0033, D33).
|
||||
late final PaneWidths paneWidths = _paneWidths ?? PaneWidths();
|
||||
|
||||
/// The projects folded in Browse and the sidebar, kept on the device.
|
||||
late final FoldedProjects foldedProjects =
|
||||
_foldedProjects ?? FoldedProjects();
|
||||
|
||||
/// Whether the device may watch the places planned (ADR 0033, D32).
|
||||
late final LocationReminderAccess locationAccess =
|
||||
_locationAccess ?? LocationReminderAccess(locationReminders);
|
||||
|
|
@ -462,6 +471,9 @@ Future<TodoPerson> _openPersonData(
|
|||
locationReminders: locationReminders,
|
||||
locationAccess: locationAccess,
|
||||
paneWidths: paneWidths,
|
||||
foldedProjects: await FoldedProjects.open(
|
||||
File(p.join(folder.path, 'folded_projects.json')),
|
||||
),
|
||||
calendarFeeds: HttpCalendarFeeds(),
|
||||
calendarStore: FileCalendarStore(
|
||||
Directory(p.join(folder.path, 'calendars')),
|
||||
|
|
|
|||
|
|
@ -271,7 +271,12 @@ class const LabelScreen({required final String labelId, super.key})
|
|||
}
|
||||
|
||||
/// A filter as a row: its colour, its name, and opening it.
|
||||
Widget filterRow(BuildContext context, StoredFilter filter) => ListTile(
|
||||
Widget filterRow(
|
||||
BuildContext context,
|
||||
StoredFilter filter, {
|
||||
bool dense = false,
|
||||
}) => ListTile(
|
||||
dense: dense,
|
||||
leading: Icon(
|
||||
LucideIcons.listFilter,
|
||||
color: switch (filter.content!.color) {
|
||||
|
|
@ -284,7 +289,12 @@ Widget filterRow(BuildContext context, StoredFilter filter) => ListTile(
|
|||
);
|
||||
|
||||
/// A label as a row: its colour, its name, and opening it.
|
||||
Widget labelRow(BuildContext context, StoredLabel label) => ListTile(
|
||||
Widget labelRow(
|
||||
BuildContext context,
|
||||
StoredLabel label, {
|
||||
bool dense = false,
|
||||
}) => ListTile(
|
||||
dense: dense,
|
||||
leading: Icon(
|
||||
LucideIcons.tag,
|
||||
color: switch (label.content!.color) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// The projects whose sub-projects are folded away in Browse and the
|
||||
/// sidebar (decided by the developer, 10 Nov 2026): kept per device, in the
|
||||
/// person's folder, as how a list shows is the device's matter, neither
|
||||
/// sealed nor synced. Written to [_file], where there is one, at every
|
||||
/// change; for this session alone without.
|
||||
class FoldedProjects([final File? _file]) extends ChangeNotifier {
|
||||
final _folded = <String>{};
|
||||
|
||||
/// Opens what [file] holds; nothing folded where it can't be read.
|
||||
static Future<FoldedProjects> open(File file) async {
|
||||
final folded = FoldedProjects(file);
|
||||
try {
|
||||
if (file.existsSync()) {
|
||||
final json = jsonDecode(await file.readAsString());
|
||||
if (json is List<Object?>) {
|
||||
folded._folded.addAll(json.whereType<String>());
|
||||
}
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Folds not read: ${error.runtimeType}', name: 'Projects');
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
/// Whether the project [id]'s sub-projects are folded away.
|
||||
bool isFolded(String id) => _folded.contains(id);
|
||||
|
||||
/// Folds the project [id]'s sub-projects away, or shows them again, and
|
||||
/// keeps it.
|
||||
Future<void> toggle(String id) async {
|
||||
if (!_folded.remove(id)) {
|
||||
_folded.add(id);
|
||||
}
|
||||
notifyListeners();
|
||||
try {
|
||||
await _file?.writeAsString(jsonEncode([..._folded]));
|
||||
} on Object catch (error) {
|
||||
log('Folds not kept: ${error.runtimeType}', name: 'Projects');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/folded_projects.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/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';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The person's projects, the Inbox left out (it is a tab of its own).
|
||||
List<StoredProject> listedProjects(TodoState state) => [
|
||||
for (final project in state.projects)
|
||||
if (!project.inbox && project.content != null) project,
|
||||
];
|
||||
|
||||
/// Whether anything is marked favourite: a project, a filter or a label.
|
||||
bool hasFavorites(TodoState state) =>
|
||||
listedProjects(state).any((project) => project.content!.favorite) ||
|
||||
state.filters.any((filter) => filter.content!.favorite) ||
|
||||
state.labels.any((label) => label.content!.favorite);
|
||||
|
||||
/// The favourites as Todoist lists them (ADR 0033, D28): the projects, then
|
||||
/// the filters, then the labels marked favourite; [dense] in the sidebar.
|
||||
List<Widget> favoriteRows(
|
||||
BuildContext context,
|
||||
TodoState state, {
|
||||
bool dense = false,
|
||||
}) => [
|
||||
for (final project in listedProjects(state))
|
||||
if (project.content!.favorite)
|
||||
_ProjectRow(state: state, project: project, dense: dense),
|
||||
for (final filter in state.filters)
|
||||
if (filter.content!.favorite) filterRow(context, filter, dense: dense),
|
||||
for (final label in state.labels)
|
||||
if (label.content!.favorite) labelRow(context, label, dense: dense),
|
||||
];
|
||||
|
||||
/// The projects as a tree (D28), each sub-project indented under its
|
||||
/// parent, those under a project [folded] away left out; [dense] in the
|
||||
/// sidebar. Browse and the sidebar fold alike, as the device keeps it
|
||||
/// (decided by the developer, 10 Nov 2026).
|
||||
List<Widget> projectTreeRows(
|
||||
TodoState state,
|
||||
FoldedProjects folded, {
|
||||
bool dense = false,
|
||||
}) {
|
||||
final hidden = {
|
||||
for (final node in projectTree(state.projects))
|
||||
if (folded.isFolded(node.project.id))
|
||||
...descendantsOf(state.projects, node.project.id),
|
||||
};
|
||||
return [
|
||||
for (final node in projectTree(state.projects))
|
||||
if (!hidden.contains(node.project.id))
|
||||
_ProjectRow(
|
||||
state: state,
|
||||
project: node.project,
|
||||
depth: node.depth,
|
||||
dense: dense,
|
||||
folds: node.hasChildren ? folded : null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// A project's row: its colour, its name, how many of its tasks are open,
|
||||
/// the chevron folding its sub-projects where [folds] is given, and opening
|
||||
/// it.
|
||||
class const _ProjectRow({
|
||||
required final TodoState state,
|
||||
required final StoredProject project,
|
||||
final int depth = 0,
|
||||
final bool dense = false,
|
||||
final FoldedProjects? folds,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final folded = folds?.isFolded(project.id);
|
||||
const start = 16.0;
|
||||
return ListTile(
|
||||
dense: dense,
|
||||
contentPadding: EdgeInsetsDirectional.only(
|
||||
start: start + (dense ? 16.0 : 24.0) * depth,
|
||||
end: folded == null ? start : 4,
|
||||
),
|
||||
leading: Icon(
|
||||
LucideIcons.hash,
|
||||
color: switch (state.colorOf(project.id)) {
|
||||
final color? => paletteText(context, color),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
project.content!.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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: () => folds!.toggle(project.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.go(TodoPaths.project(project.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +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/app/todo_paths.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_screen.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_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_lists.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';
|
||||
import 'package:halkyon_todo_flutter/src/settings/settings_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -32,16 +28,9 @@ const filtersEntryKey = ValueKey('filters-entry');
|
|||
/// 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>{};
|
||||
|
||||
/// chevron, folded as the device keeps it, alike in the sidebar (decided
|
||||
/// by the developer, 10 Nov 2026; `project_lists.dart`).
|
||||
class const BrowseTab({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -64,65 +53,7 @@ class _BrowseTabState() extends State<BrowseTab> {
|
|||
],
|
||||
),
|
||||
);
|
||||
final projects = [
|
||||
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,
|
||||
];
|
||||
final favoriteFilters = [
|
||||
for (final filter in state.filters)
|
||||
if (filter.content!.favorite) filter,
|
||||
];
|
||||
final favoriteLabels = [
|
||||
for (final label in state.labels)
|
||||
if (label.content!.favorite) label,
|
||||
];
|
||||
// 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 (state.colorOf(project.id)) {
|
||||
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)),
|
||||
);
|
||||
final projects = listedProjects(state);
|
||||
return TabScreen(
|
||||
title: l10n.shellTabBrowse,
|
||||
actions: const [SettingsButton()],
|
||||
|
|
@ -153,9 +84,7 @@ class _BrowseTabState() extends State<BrowseTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (favorites.isNotEmpty ||
|
||||
favoriteFilters.isNotEmpty ||
|
||||
favoriteLabels.isNotEmpty) ...[
|
||||
if (hasFavorites(state)) ...[
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
|
|
@ -168,12 +97,7 @@ class _BrowseTabState() extends State<BrowseTab> {
|
|||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
for (final project in favorites)
|
||||
projectRow(project),
|
||||
for (final filter in favoriteFilters)
|
||||
filterRow(context, filter),
|
||||
for (final label in favoriteLabels)
|
||||
labelRow(context, label),
|
||||
...favoriteRows(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -213,15 +137,16 @@ class _BrowseTabState() extends State<BrowseTab> {
|
|||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
ListenableBuilder(
|
||||
listenable: todo.foldedProjects,
|
||||
builder: (context, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: projectTreeRows(
|
||||
state,
|
||||
todo.foldedProjects,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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/feature/project/widget/project_lists.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What Todo's open sidebar lists below the tabs, as Todoist's (decided by
|
||||
/// the developer, 10 Nov 2026): the favourites, then the projects as a tree
|
||||
/// with their open counts, sub-projects folding as in Browse, the folds kept
|
||||
/// on the device. Nothing until the person's data is read.
|
||||
class const TodoSidebarSections({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final folded = PersonScope.of<TodoPerson>(context).foldedProjects;
|
||||
if (!state.loaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
Widget heading(String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
// The rows' icons under the tabs' icons, 40 from the start.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: ListenableBuilder(
|
||||
listenable: folded,
|
||||
builder: (context, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasFavorites(state)) ...[
|
||||
heading(l10n.favoritesTitle),
|
||||
...favoriteRows(context, state, dense: true),
|
||||
],
|
||||
if (listedProjects(state).isNotEmpty) ...[
|
||||
heading(l10n.projectsTitle),
|
||||
...projectTreeRows(state, folded, dense: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/navigation_bar_hold.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/sidebar_sections.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -58,6 +59,7 @@ class _TodoShellState() extends State<TodoShell> {
|
|||
),
|
||||
onSidebarChanged: (open) =>
|
||||
context.read<LookCubit>().chooseSidebar(open: open),
|
||||
sidebarSections: const TodoSidebarSections(),
|
||||
appName: l10n.appTitle,
|
||||
icon: todoIcon,
|
||||
// The bar's wave and chosen tab: Todo's strawberry, its screens' wash.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/folded_projects.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';
|
||||
|
|
@ -95,6 +96,9 @@ void main() {
|
|||
late MemoryLocationGateway locationGateway;
|
||||
late FakeSpeechInput speech;
|
||||
|
||||
/// The projects folded in Browse and the sidebar.
|
||||
late FoldedProjects folded;
|
||||
|
||||
/// How many syncs ran.
|
||||
late int syncs;
|
||||
|
||||
|
|
@ -128,6 +132,7 @@ void main() {
|
|||
weather = FakeWeather();
|
||||
locationGateway = MemoryLocationGateway();
|
||||
speech = FakeSpeechInput();
|
||||
folded = FoldedProjects();
|
||||
store = MemoryInstanceStore();
|
||||
looks = MemoryLookStore();
|
||||
gateway = FakeAuthGateway();
|
||||
|
|
@ -233,6 +238,7 @@ void main() {
|
|||
reminders: reminders,
|
||||
locationReminders: locationGateway,
|
||||
calendarFeeds: calendarFeeds,
|
||||
foldedProjects: folded,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -4778,6 +4784,96 @@ void main() {
|
|||
expect(find.byKey(NavigationFrame.sidebarKey), findsOneWidget);
|
||||
expect(looks.writes.last.sidebarOpen, isTrue);
|
||||
});
|
||||
|
||||
/// Seeds Home, a favourite, with its sub-project Garden, where Weed
|
||||
/// is open, and Work, where Report is; opens the sidebar.
|
||||
Future<Map<String, String>> openWithProjects(WidgetTester tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
final ids = await tester.runAsync(() async {
|
||||
final projects = ProjectRepository(projectRows, keys);
|
||||
final home = await projects.save(
|
||||
const ProjectContent(name: 'Home', position: 1, favorite: true),
|
||||
);
|
||||
final garden = await projects.save(
|
||||
ProjectContent(name: 'Garden', position: 2, parentId: home),
|
||||
);
|
||||
final work = await projects.save(
|
||||
const ProjectContent(name: 'Work', position: 3),
|
||||
);
|
||||
final tasks = TaskRepository(taskRows, keys);
|
||||
await tasks.add(TaskContent(title: 'Weed', projectId: garden));
|
||||
await tasks.add(TaskContent(title: 'Report', projectId: work));
|
||||
return {'Home': home, 'Garden': garden, 'Work': work};
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
taskRows.bringRemoteChanges();
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Open sidebar'));
|
||||
await tester.pumpAndSettle();
|
||||
return ids!;
|
||||
}
|
||||
|
||||
Finder inSidebar(Finder finder) => find.descendant(
|
||||
of: find.byKey(NavigationFrame.sidebarKey),
|
||||
matching: finder,
|
||||
);
|
||||
|
||||
testWidgets('open, it lists the favourites, then the projects as a '
|
||||
'tree with their open tasks counted', (tester) async {
|
||||
await openWithProjects(tester);
|
||||
|
||||
// The test's language is American English.
|
||||
final favorites = tester.getTopLeft(inSidebar(find.text('Favorites')));
|
||||
final projects = tester.getTopLeft(inSidebar(find.text('Projects')));
|
||||
expect(favorites.dy, lessThan(projects.dy));
|
||||
final homes = inSidebar(find.text('Home'));
|
||||
expect(homes, findsNWidgets(2), reason: 'a favourite and a project');
|
||||
expect(tester.getTopLeft(homes.first).dy, lessThan(projects.dy));
|
||||
expect(
|
||||
tester.getTopLeft(inSidebar(find.text('Garden'))).dx,
|
||||
greaterThan(tester.getTopLeft(homes.last).dx),
|
||||
reason: 'indented under its parent',
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.ancestor(
|
||||
of: inSidebar(find.text('Garden')),
|
||||
matching: find.byType(ListTile),
|
||||
),
|
||||
matching: find.text('1'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('folding a project there folds it in Browse too, kept on '
|
||||
'the device', (tester) async {
|
||||
final ids = await openWithProjects(tester);
|
||||
|
||||
await tester.tap(inSidebar(find.byTooltip('Hide sub-projects')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(inSidebar(find.text('Garden')), findsNothing);
|
||||
expect(folded.isFolded(ids['Home']!), isTrue);
|
||||
await tester.tap(inSidebar(find.text('Browse')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Garden'), findsNothing);
|
||||
expect(
|
||||
find.byTooltip('Show sub-projects'),
|
||||
findsNWidgets(2),
|
||||
reason: "the sidebar's and Browse's",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a project tapped there opens', (tester) async {
|
||||
await openWithProjects(tester);
|
||||
|
||||
await tester.tap(inSidebar(find.text('Work')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Report'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group("a project's linked calendar (D39, the developer's decisions of "
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/folded_projects.dart';
|
||||
|
||||
/// Which projects' sub-projects are folded away, kept per device (the
|
||||
/// developer's decision of 10 Nov 2026), in Browse and the sidebar alike.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('folded');
|
||||
file = File('${folder.path}/folded_projects.json');
|
||||
});
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
test('nothing is folded at first', () async {
|
||||
final folded = await FoldedProjects.open(file);
|
||||
|
||||
expect(folded.isFolded('school'), isFalse);
|
||||
});
|
||||
|
||||
test('a fold is kept for the next start, an unfold too', () async {
|
||||
final folded = await FoldedProjects.open(file);
|
||||
var told = 0;
|
||||
folded.addListener(() => told++);
|
||||
|
||||
await folded.toggle('school');
|
||||
await folded.toggle('home');
|
||||
await folded.toggle('home');
|
||||
|
||||
expect(told, 3);
|
||||
final again = await FoldedProjects.open(file);
|
||||
expect(again.isFolded('school'), isTrue);
|
||||
expect(again.isFolded('home'), isFalse);
|
||||
});
|
||||
|
||||
test('a damaged file folds nothing', () async {
|
||||
file.writeAsStringSync('{"school": ');
|
||||
|
||||
expect((await FoldedProjects.open(file)).isFolded('school'), isFalse);
|
||||
});
|
||||
|
||||
test('without a file it folds for this session alone', () async {
|
||||
final folded = FoldedProjects();
|
||||
await folded.toggle('school');
|
||||
|
||||
expect(folded.isFolded('school'), isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue