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
2 changed files with 413 additions and 94 deletions

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