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

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.