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