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:
Tizian 「ティツィアーン」 2026-11-10 17:03:17 +01:00
commit a37ca123b1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 413 additions and 94 deletions

File diff suppressed because one or more lines are too long

View file

@ -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')),

View file

@ -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) {

View file

@ -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');
}
}
}

View file

@ -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)),
);
}
}

View file

@ -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,
),
),
),
],
),
),

View file

@ -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),
],
],
),
),
);
}
}

View file

@ -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.

View file

@ -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 "

View file

@ -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);
});
}