feat(todo): dialogs on the desktop, the tabs reordered, a task's path and menu (ADR 0033, D11 to D14)
- halkyon_ui: showAdaptiveSheet shows a dialog on Linux, macOS and Windows at every width (bottom sheets stay on phones and tablets below expanded windows), so Learn and Train follow; SheetScaffold takes a heading in the title's place and may leave out its close button. - Todo: the tabs are Inbox, Today, Upcoming, Browse, Today still opening; `serverpod start` launches the app on Linux, as it has no web build. - A task's sheet shows where the task is (its project, an icon once tasks are above it, and the tasks above it, each opening what it names) and a ⋮ menu in place of its close button: when it was added (its UUID v7), Duplicate with its sub-tasks, hiding its done sub-tasks (synced in its payload), Close in the pane, Delete after asking. The description's icon stays by its first line. - A row names its project and section at the end of its date's line; every menu entry has its icon. - Learn: a studyset's path starts at the Library tab's icon, named by its tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
666e8dbcbb
commit
b908019969
25 changed files with 1050 additions and 125 deletions
|
|
@ -42,7 +42,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
|
||||
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab (the tab row folding into the title as the content scrolls, halkyon_ui's `FoldingTabs`, as Strava's). The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
|
||||
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
|
||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows on phones and tablets, and a dialog from there on and on the desktop at every width (Todo's ADR 0033, D11); a sheet may put a widget of its own in its title's place (`heading`) and leave out its close button where its action's menu holds Close (`closeButton`). A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
|
||||
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (halkyon_ui) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, an email address 254, a message or a web address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
|
||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (halkyon_app's, shared by every Halkyon app) picks the language, never by country alone, and every `MaterialApp` takes `appLocalizationsDelegates` (halkyon_app's and halkyon_ui's texts among them; halkyon_app's plain apps take it from `learnFrame`), not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import 'dart:math' as math;
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where a studyset sits, on one line: "Library › Japanese › Kana ›
|
||||
/// Hiragana", the steps as plain links in a muted colour, the studyset
|
||||
/// itself last, bold in the primary colour.
|
||||
/// Where a studyset sits, on one line: "📖 › Japanese › Kana › Hiragana",
|
||||
/// the library as the Library tab's icon, named by its tooltip (as Todo's
|
||||
/// path starts at its project's icon, Todo's ADR 0033, D13), the other
|
||||
/// steps as plain links in a muted colour, the studyset itself last, bold
|
||||
/// in the primary colour.
|
||||
///
|
||||
/// Every step before it goes back up there ([goUpTo]): the screens behind
|
||||
/// a studyset are its path, so going up closes the screens above, as the
|
||||
|
|
@ -40,6 +43,9 @@ class const StudysetBreadcrumb({
|
|||
/// How high every step is, to tap.
|
||||
static const minTarget = 48.0;
|
||||
|
||||
/// How wide a step shown as its icon is.
|
||||
static const _iconWidth = 32.0;
|
||||
|
||||
/// How far before the screen's end the line ends when [trailing] ends it.
|
||||
static const trailingEndPadding = 8.0;
|
||||
|
||||
|
|
@ -57,6 +63,7 @@ class const StudysetBreadcrumb({
|
|||
_Step(
|
||||
l10n.libraryTitle,
|
||||
enabled ? () => goUpTo(context, null, all) : null,
|
||||
icon: LucideIcons.book,
|
||||
),
|
||||
for (final ancestor in ancestors)
|
||||
_Step(
|
||||
|
|
@ -85,7 +92,10 @@ class const StudysetBreadcrumb({
|
|||
}
|
||||
|
||||
final separatorWidth = widthOf('›', muted) + 2 * _separatorPadding;
|
||||
final widths = [for (final step in steps) widthOf(step.label, muted)];
|
||||
final widths = [
|
||||
for (final step in steps)
|
||||
if (step.icon == null) widthOf(step.label, muted) else _iconWidth,
|
||||
];
|
||||
final currentWidth = widthOf(current.content.title, active);
|
||||
const foldWidth = _Fold.width;
|
||||
// Fold the first steps away until the rest fits; at the least, the
|
||||
|
|
@ -120,13 +130,23 @@ class const StudysetBreadcrumb({
|
|||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: step.onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: minTarget),
|
||||
child: Center(
|
||||
widthFactor: 1,
|
||||
child: Text(step.label, maxLines: 1, style: muted),
|
||||
child: switch (step.icon) {
|
||||
final icon? => Tooltip(
|
||||
message: step.label,
|
||||
child: SizedBox(
|
||||
width: _iconWidth,
|
||||
height: minTarget,
|
||||
child: Icon(icon, size: 18, color: muted.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
null => ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: minTarget),
|
||||
child: Center(
|
||||
widthFactor: 1,
|
||||
child: Text(step.label, maxLines: 1, style: muted),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
separator(),
|
||||
|
|
@ -160,8 +180,13 @@ class const StudysetBreadcrumb({
|
|||
}
|
||||
}
|
||||
|
||||
/// A step of the way, and where it leads; nowhere while null.
|
||||
class const _Step(final String label, final VoidCallback? onTap);
|
||||
/// A step of the way, and where it leads; nowhere while null. One with an
|
||||
/// [icon] shows it, its [label] the tooltip, and both in the fold's menu.
|
||||
class const _Step(
|
||||
final String label,
|
||||
final VoidCallback? onTap, {
|
||||
final IconData? icon,
|
||||
});
|
||||
|
||||
/// The steps that didn't fit, behind a "…" that turns primary under the
|
||||
/// pointer, nearest first in its menu; a square target as high as a step.
|
||||
|
|
@ -184,7 +209,11 @@ class _FoldState() extends State<_Fold> {
|
|||
Widget build(BuildContext context) => MenuAnchor(
|
||||
menuChildren: [
|
||||
for (final step in widget.steps)
|
||||
MenuItemButton(onPressed: step.onTap, child: Text(step.label)),
|
||||
MenuItemButton(
|
||||
onPressed: step.onTap,
|
||||
leadingIcon: step.icon == null ? null : Icon(step.icon),
|
||||
child: Text(step.label),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
|
|
|
|||
|
|
@ -321,7 +321,22 @@ void main() {
|
|||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.text(text),
|
||||
);
|
||||
expect(step('Library'), findsOneWidget);
|
||||
// The library is its icon, named by its tooltip (Todo's ADR 0033).
|
||||
expect(step('Library'), findsNothing);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.byTooltip('Library'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.byIcon(LucideIcons.book),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(step('Japanese'), findsOneWidget);
|
||||
expect(step('Kana'), findsOneWidget);
|
||||
expect(step('›'), findsNWidgets(2));
|
||||
|
|
@ -1412,7 +1427,7 @@ void main() {
|
|||
.getCenter(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.text('Library'),
|
||||
matching: find.byIcon(LucideIcons.book),
|
||||
),
|
||||
)
|
||||
.dy,
|
||||
|
|
@ -1576,7 +1591,9 @@ void main() {
|
|||
.ancestor(
|
||||
of: find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.text(text),
|
||||
matching: text == 'Library'
|
||||
? find.byIcon(LucideIcons.book)
|
||||
: find.text(text),
|
||||
),
|
||||
matching: find.byType(GestureDetector),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
|
|||
|
||||
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
|
||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart` on Linux, `device: linux`, as Todo has no web build); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||
|
|
|
|||
|
|
@ -137,5 +137,13 @@
|
|||
"searchEmpty": "Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.",
|
||||
"searchNone": "Nichts gefunden für „{query}“.",
|
||||
"searchProjects": "Projekte",
|
||||
"searchTasks": "Aufgaben"
|
||||
"searchTasks": "Aufgaben",
|
||||
"taskMore": "Optionen der Aufgabe",
|
||||
"taskAddedOn": "Hinzugefügt am {when}",
|
||||
"taskDuplicate": "Duplizieren",
|
||||
"taskDuplicated": "Aufgabe dupliziert",
|
||||
"subTasksHideDone": "Erledigte Unteraufgaben ausblenden",
|
||||
"subTasksShowDone": "Erledigte Unteraufgaben zeigen",
|
||||
"taskClose": "Schließen",
|
||||
"taskPath": "Wo die Aufgabe liegt"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,5 +167,14 @@
|
|||
"searchNone": "Nothing found for “{query}”.",
|
||||
"@searchNone": {"placeholders": {"query": {"type": "String"}}},
|
||||
"searchProjects": "Projects",
|
||||
"searchTasks": "Tasks"
|
||||
"searchTasks": "Tasks",
|
||||
"taskMore": "Task options",
|
||||
"taskAddedOn": "Added on {when}",
|
||||
"@taskAddedOn": {"placeholders": {"when": {"type": "String"}}},
|
||||
"taskDuplicate": "Duplicate",
|
||||
"taskDuplicated": "Task duplicated",
|
||||
"subTasksHideDone": "Hide done sub-tasks",
|
||||
"subTasksShowDone": "Show done sub-tasks",
|
||||
"taskClose": "Close",
|
||||
"taskPath": "Where the task is"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -891,6 +891,54 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Tasks'**
|
||||
String get searchTasks;
|
||||
|
||||
/// No description provided for @taskMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Task options'**
|
||||
String get taskMore;
|
||||
|
||||
/// No description provided for @taskAddedOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added on {when}'**
|
||||
String taskAddedOn(String when);
|
||||
|
||||
/// No description provided for @taskDuplicate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duplicate'**
|
||||
String get taskDuplicate;
|
||||
|
||||
/// No description provided for @taskDuplicated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Task duplicated'**
|
||||
String get taskDuplicated;
|
||||
|
||||
/// No description provided for @subTasksHideDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide done sub-tasks'**
|
||||
String get subTasksHideDone;
|
||||
|
||||
/// No description provided for @subTasksShowDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show done sub-tasks'**
|
||||
String get subTasksShowDone;
|
||||
|
||||
/// No description provided for @taskClose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get taskClose;
|
||||
|
||||
/// No description provided for @taskPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where the task is'**
|
||||
String get taskPath;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -552,4 +552,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchTasks => 'Aufgaben';
|
||||
|
||||
@override
|
||||
String get taskMore => 'Optionen der Aufgabe';
|
||||
|
||||
@override
|
||||
String taskAddedOn(String when) {
|
||||
return 'Hinzugefügt am $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskDuplicate => 'Duplizieren';
|
||||
|
||||
@override
|
||||
String get taskDuplicated => 'Aufgabe dupliziert';
|
||||
|
||||
@override
|
||||
String get subTasksHideDone => 'Erledigte Unteraufgaben ausblenden';
|
||||
|
||||
@override
|
||||
String get subTasksShowDone => 'Erledigte Unteraufgaben zeigen';
|
||||
|
||||
@override
|
||||
String get taskClose => 'Schließen';
|
||||
|
||||
@override
|
||||
String get taskPath => 'Wo die Aufgabe liegt';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -552,6 +552,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchTasks => 'Tasks';
|
||||
|
||||
@override
|
||||
String get taskMore => 'Task options';
|
||||
|
||||
@override
|
||||
String taskAddedOn(String when) {
|
||||
return 'Added on $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskDuplicate => 'Duplicate';
|
||||
|
||||
@override
|
||||
String get taskDuplicated => 'Task duplicated';
|
||||
|
||||
@override
|
||||
String get subTasksHideDone => 'Hide done sub-tasks';
|
||||
|
||||
@override
|
||||
String get subTasksShowDone => 'Show done sub-tasks';
|
||||
|
||||
@override
|
||||
String get taskClose => 'Close';
|
||||
|
||||
@override
|
||||
String get taskPath => 'Where the task is';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -19,8 +19,17 @@ GoRouter buildTodoRouter() => GoRouter(
|
|||
builder: (context, state, shell) => TodoShell(shell: shell),
|
||||
navigatorContainerBuilder: (context, shell, tabs) =>
|
||||
TabsStack(index: shell.currentIndex, children: tabs),
|
||||
// In the order of the shell's destinations.
|
||||
// In the order of the shell's destinations (the developer's, 1 Nov:
|
||||
// Inbox first, though Today, at `/`, opens).
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: TodoPaths.inbox,
|
||||
builder: (context, state) => const InboxTab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
|
@ -37,14 +46,6 @@ GoRouter buildTodoRouter() => GoRouter(
|
|||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: TodoPaths.inbox,
|
||||
builder: (context, state) => const InboxTab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What a menu's entry shows (the developer's, 1 Nov 2026): its [icon]
|
||||
/// leading its [label], as Todoist's and Learn's menus have them; a
|
||||
/// [destructive] one, as deleting, in the error colour.
|
||||
class const MenuEntry(
|
||||
final IconData icon,
|
||||
final String label, {
|
||||
final bool destructive = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = destructive ? Theme.of(context).colorScheme.error : null;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: color == null ? null : TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'package:go_router/go_router.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/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -41,13 +42,14 @@ class _ProjectScreenBodyState() extends State<ProjectScreenBody> {
|
|||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => setState(() => _completed = !_completed),
|
||||
child: Text(
|
||||
child: MenuEntry(
|
||||
_completed ? LucideIcons.eyeOff : LucideIcons.circleCheck,
|
||||
_completed ? l10n.completedHide : l10n.completedShow,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => addSection(context, projectId),
|
||||
child: Text(l10n.sectionAdd),
|
||||
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
|
||||
),
|
||||
...?widget.menu?.call(context),
|
||||
],
|
||||
|
|
@ -116,7 +118,7 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
await todo.projects.update(projectId, edited);
|
||||
}
|
||||
},
|
||||
child: Text(l10n.projectEdit),
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.projectEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
|
|
@ -132,7 +134,11 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
router.pop();
|
||||
}
|
||||
},
|
||||
child: Text(l10n.projectDelete),
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.projectDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -110,15 +111,19 @@ class const ProjectTasks({
|
|||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.add,
|
||||
child: Text(l10n.taskAddTitle),
|
||||
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.edit,
|
||||
child: Text(l10n.sectionEdit),
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.delete,
|
||||
child: Text(l10n.sectionDelete),
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.sectionDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
|||
'completions': [
|
||||
for (final at in content.completions) at.toUtc().toIso8601String(),
|
||||
],
|
||||
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true,
|
||||
});
|
||||
|
||||
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
|
||||
|
|
@ -93,6 +94,7 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
|||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||
DateTime.parse(at! as String).toLocal(),
|
||||
],
|
||||
hideDoneSubTasks: json['hideDoneSubTasks'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
|
|
@ -91,6 +93,43 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Copies the task [id] with its sub-tasks, theirs too, each as it is,
|
||||
/// the copy right after it among its siblings, and returns the copy's ID
|
||||
/// (the developer's, 1 Nov: the task's menu's Duplicate, as Todoist's).
|
||||
Future<String> duplicate(String id) async {
|
||||
final tasks = await all();
|
||||
final contents = {for (final task in tasks) task.id: ?task.content};
|
||||
final original = contents[id];
|
||||
if (original == null) {
|
||||
throw StateError('No task $id here');
|
||||
}
|
||||
final after = [
|
||||
for (final content in contents.values)
|
||||
if (content.parentId == original.parentId &&
|
||||
content.projectId == original.projectId &&
|
||||
content.sectionId == original.sectionId &&
|
||||
content.position > original.position)
|
||||
content.position,
|
||||
];
|
||||
final position = after.isEmpty
|
||||
? original.position + 1
|
||||
: (original.position + after.reduce(min)) / 2;
|
||||
final copy = await add(original.copyWith(position: position));
|
||||
Future<void> copyBelow(String from, String to) async {
|
||||
for (final entry in contents.entries.toList()) {
|
||||
if (entry.value.parentId == from) {
|
||||
final childCopy = await add(
|
||||
entry.value.copyWith(parentId: () => to),
|
||||
);
|
||||
await copyBelow(entry.key, childCopy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyBelow(id, copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// Deletes the task [id] with its sub-tasks, theirs too, together.
|
||||
Future<void> delete(String id) async {
|
||||
final tasks = await all();
|
||||
|
|
|
|||
|
|
@ -149,6 +149,11 @@ final class const TaskContent({
|
|||
/// When a recurring task's past times were done, the oldest first (D6),
|
||||
/// for Reporting to count.
|
||||
final List<DateTime> completions = const [],
|
||||
|
||||
/// Whether its sheet leaves out its sub-tasks that are done (the
|
||||
/// developer's, 1 Nov: its menu's Hide completed sub-tasks, as Todoist
|
||||
/// keeps it per task, on every device).
|
||||
final bool hideDoneSubTasks = false,
|
||||
}) with Equatable {
|
||||
/// Whether it is done.
|
||||
bool get completed => completedAt != null;
|
||||
|
|
@ -167,6 +172,7 @@ final class const TaskContent({
|
|||
List<int>? reminders,
|
||||
DateTime? Function()? completedAt,
|
||||
List<DateTime>? completions,
|
||||
bool? hideDoneSubTasks,
|
||||
}) => TaskContent(
|
||||
title: title ?? this.title,
|
||||
projectId: projectId ?? this.projectId,
|
||||
|
|
@ -181,6 +187,7 @@ final class const TaskContent({
|
|||
reminders: reminders ?? this.reminders,
|
||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||
completions: completions ?? this.completions,
|
||||
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -198,6 +205,7 @@ final class const TaskContent({
|
|||
reminders,
|
||||
completedAt,
|
||||
completions,
|
||||
hideDoneSubTasks,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,3 +118,17 @@ int openCountIn(List<OpenTask> tasks, String projectId) => tasks
|
|||
(task) => task.content.projectId == projectId && !task.content.completed,
|
||||
)
|
||||
.length;
|
||||
|
||||
/// When the task [id] was added, as its UUID v7 says (its first 48 bits,
|
||||
/// milliseconds since 1970), in local time; null for an ID of another kind.
|
||||
/// The instance knows as much from the row, so the payload needn't say it.
|
||||
DateTime? addedAt(String id) {
|
||||
final hex = id.replaceAll('-', '');
|
||||
if (hex.length != 32 || hex[12] != '7') {
|
||||
return null;
|
||||
}
|
||||
return switch (int.tryParse(hex.substring(0, 12), radix: 16)) {
|
||||
final ms? => DateTime.fromMillisecondsSinceEpoch(ms),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import 'dart:async';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/app/todo_paths.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
|
|
@ -21,11 +25,14 @@ import 'package:halkyon_ui/halkyon_ui.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the task [id]'s sheet (ADR 0033, D8): its title and description to
|
||||
/// edit, a row for each of its date, deadline, priority, project and labels,
|
||||
/// its sub-tasks with one more to add, and deleting it, which asks first.
|
||||
/// From expanded windows on, where a [TaskPane] is above, it opens in the
|
||||
/// pane beside the list instead (D2).
|
||||
/// Opens the task [id]'s sheet (ADR 0033, D8): where it is on top (its
|
||||
/// project, an icon once tasks are above it, and the tasks above it, each
|
||||
/// opening what it names), its menu (when it was added, Duplicate, hiding
|
||||
/// its done sub-tasks, deleting it after asking), its title and
|
||||
/// description to edit, a row for each of its date, deadline, priority,
|
||||
/// project and labels, and its sub-tasks with one more to add. From
|
||||
/// expanded windows on, where a [TaskPane] is above, it opens in the pane
|
||||
/// beside the list instead (D2).
|
||||
Future<void> openTaskSheet(BuildContext context, String id) async {
|
||||
if (context.windowSize.isAtLeastExpanded) {
|
||||
if (TaskPane.maybeOf(context) case final pane?) {
|
||||
|
|
@ -35,7 +42,24 @@ Future<void> openTaskSheet(BuildContext context, String id) async {
|
|||
}
|
||||
await showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _TaskSheet(id: id),
|
||||
builder: (context) => _TaskSheetHost(id: id),
|
||||
);
|
||||
}
|
||||
|
||||
/// A sheet's task, which a step of its path replaces in place.
|
||||
class const _TaskSheetHost({required final String id}) extends StatefulWidget {
|
||||
@override
|
||||
State<_TaskSheetHost> createState() => _TaskSheetHostState();
|
||||
}
|
||||
|
||||
class _TaskSheetHostState() extends State<_TaskSheetHost> {
|
||||
late var _id = widget.id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _TaskSheet(
|
||||
key: ValueKey(_id),
|
||||
id: _id,
|
||||
onOpen: (id) => setState(() => _id = id),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +124,9 @@ class const _TaskPaneScope({
|
|||
class const _TaskSheet({
|
||||
required final String id,
|
||||
final bool inPane = false,
|
||||
|
||||
/// Opens another task in the sheet's place; in the pane, the pane does.
|
||||
final ValueChanged<String>? onOpen,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -194,6 +221,15 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens the task [id] in this one's place.
|
||||
void _open(BuildContext context, String id) {
|
||||
if (widget.inPane) {
|
||||
TaskPane.maybeOf(context)?.value = id;
|
||||
} else {
|
||||
widget.onOpen?.call(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// What closes it: the pane's close, or the sheet's route.
|
||||
VoidCallback _closer(BuildContext context) {
|
||||
if (widget.inPane) {
|
||||
|
|
@ -204,17 +240,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
return sheet.pop;
|
||||
}
|
||||
|
||||
/// Its frame: the sheet's, or the pane's, its title, action and close
|
||||
/// button on top and the rest scrolling below.
|
||||
/// Its frame: the sheet's, or the pane's, its [heading] (else its
|
||||
/// title) and [action] on top and the rest scrolling below. With an
|
||||
/// action, the action's menu closes it, not a close button of its own.
|
||||
Widget _frame(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required Widget body,
|
||||
Widget? heading,
|
||||
Widget? action,
|
||||
}) {
|
||||
if (!widget.inPane) {
|
||||
return SheetScaffold(
|
||||
title: title,
|
||||
heading: heading,
|
||||
closeButton: action == null,
|
||||
hasSnackBars: true,
|
||||
action: action,
|
||||
body: body,
|
||||
|
|
@ -228,18 +268,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child:
|
||||
heading ??
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?action,
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: _closer(context),
|
||||
),
|
||||
if (action == null)
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: _closer(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -253,6 +296,99 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
/// The task's menu (the developer's, 1 Nov, in place of the close
|
||||
/// button): when it was added, Duplicate, hiding or showing its done
|
||||
/// sub-tasks, Close in the pane, and deleting it, which asks first.
|
||||
Widget _menu(BuildContext context, OpenTask task, List<OpenTask> subTasks) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final content = task.content;
|
||||
final added = addedAt(task.id);
|
||||
final material = MaterialLocalizations.of(context);
|
||||
return PopupMenuButton<Future<void> Function()>(
|
||||
tooltip: l10n.taskMore,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onSelected: (action) => unawaited(action()),
|
||||
itemBuilder: (context) => [
|
||||
if (added != null) ...[
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text(
|
||||
l10n.taskAddedOn(
|
||||
'${material.formatMediumDate(added)} · '
|
||||
'${material.formatTimeOfDay(
|
||||
TimeOfDay.fromDateTime(added),
|
||||
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
|
||||
)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await _tasks.duplicate(task.id);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.taskDuplicated)),
|
||||
);
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.keysMissing)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: MenuEntry(LucideIcons.copyPlus, l10n.taskDuplicate),
|
||||
),
|
||||
if (subTasks.any((sub) => sub.content.completed) ||
|
||||
content.hideDoneSubTasks)
|
||||
PopupMenuItem(
|
||||
value: () => _change(
|
||||
(c) => c.copyWith(hideDoneSubTasks: !c.hideDoneSubTasks),
|
||||
),
|
||||
child: MenuEntry(
|
||||
content.hideDoneSubTasks
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.eyeOff,
|
||||
content.hideDoneSubTasks
|
||||
? l10n.subTasksShowDone
|
||||
: l10n.subTasksHideDone,
|
||||
),
|
||||
),
|
||||
if (widget.inPane)
|
||||
PopupMenuItem(
|
||||
value: () async => _closer(context)(),
|
||||
child: MenuEntry(LucideIcons.x, l10n.taskClose),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
final close = _closer(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.taskDeleteTitle,
|
||||
message: subTasks.isEmpty
|
||||
? l10n.taskDeleteMessage(content.title)
|
||||
: l10n.taskDeleteWithSubTasks(
|
||||
content.title,
|
||||
subTasks.length,
|
||||
),
|
||||
confirmLabel: l10n.taskDelete,
|
||||
)) {
|
||||
await _tasks.delete(task.id);
|
||||
close();
|
||||
}
|
||||
},
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.taskDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -282,24 +418,20 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
final project? when !project.inbox => project.content!.name,
|
||||
_ => l10n.shellTabInbox,
|
||||
},
|
||||
action: IconButton(
|
||||
tooltip: l10n.taskDelete,
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () async {
|
||||
final close = _closer(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.taskDeleteTitle,
|
||||
message: subTasks.isEmpty
|
||||
? l10n.taskDeleteMessage(content.title)
|
||||
: l10n.taskDeleteWithSubTasks(content.title, subTasks.length),
|
||||
confirmLabel: l10n.taskDelete,
|
||||
)) {
|
||||
await _tasks.delete(widget.id);
|
||||
close();
|
||||
heading: _TaskPath(
|
||||
task: task,
|
||||
onOpen: (id) => _open(context, id),
|
||||
onProject: (project) {
|
||||
final router = GoRouter.of(context);
|
||||
if (!widget.inPane) {
|
||||
_closer(context)();
|
||||
}
|
||||
router.go(
|
||||
project.inbox ? TodoPaths.inbox : TodoPaths.project(project.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
action: _menu(context, task, subTasks),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -322,17 +454,29 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _description,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(LucideIcons.text),
|
||||
hintText: l10n.taskDescriptionHint,
|
||||
),
|
||||
onTapOutside: (_) => unawaited(_saveText()),
|
||||
// The icon stays by the first line however long the
|
||||
// description grows (the developer's, 1 Nov).
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: Icon(LucideIcons.text),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _description,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: l10n.taskDescriptionHint,
|
||||
),
|
||||
onTapOutside: (_) => unawaited(_saveText()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
|
|
@ -469,7 +613,9 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
style: theme.textTheme.titleSmall?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
for (final sub in subTasks) TaskTile(task: sub),
|
||||
for (final sub in subTasks)
|
||||
if (!(content.hideDoneSubTasks && sub.content.completed))
|
||||
TaskTile(task: sub),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
|
|
@ -488,3 +634,101 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of the path atop a task's sheet, for tests to find it by.
|
||||
const taskPathKey = ValueKey('task-path');
|
||||
|
||||
/// Where [task] is (the developer's, 1 Nov, as Learn's path through a
|
||||
/// studyset): its project, named while it is a task of its own and an icon
|
||||
/// once tasks are above it, then each task above it, the nearest last; a
|
||||
/// tap on the project goes to its list ([onProject]), on a task opens it
|
||||
/// in the sheet's place ([onOpen]).
|
||||
class const _TaskPath({
|
||||
required final OpenTask task,
|
||||
required final ValueChanged<String> onOpen,
|
||||
required final ValueChanged<StoredProject> onProject,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final above = <OpenTask>[];
|
||||
final seen = {task.id};
|
||||
var parent = task.content.parentId;
|
||||
while (parent != null && seen.add(parent)) {
|
||||
final next = state.taskOf(parent);
|
||||
if (next == null) {
|
||||
break;
|
||||
}
|
||||
above.insert(0, next);
|
||||
parent = next.content.parentId;
|
||||
}
|
||||
final project = state.projectOf(task.content.projectId);
|
||||
final style = theme.textTheme.titleSmall;
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
Widget step({required Widget child, required VoidCallback onTap}) =>
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
const chevron = Icon(LucideIcons.chevronRight, size: 16);
|
||||
return Semantics(
|
||||
key: taskPathKey,
|
||||
label: l10n.taskPath,
|
||||
container: true,
|
||||
child: Row(
|
||||
children: [
|
||||
if (project != null)
|
||||
step(
|
||||
onTap: () => onProject(project),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
|
||||
size: 18,
|
||||
color: switch (project.content?.color) {
|
||||
final color? => paletteText(context, color),
|
||||
null => muted,
|
||||
},
|
||||
),
|
||||
if (above.isEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
project.inbox
|
||||
? l10n.shellTabInbox
|
||||
: project.content!.name,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final step_ in above) ...[
|
||||
chevron,
|
||||
Flexible(
|
||||
child: step(
|
||||
onTap: () => onOpen(step_.id),
|
||||
child: Text(
|
||||
step_.content.title,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// its title (struck through once done), the first line of its
|
||||
/// description, and what it has: its date in the colour of how near it is,
|
||||
/// its deadline, how many of its sub-tasks are done, its labels; where
|
||||
/// [showProject], the project it is in. A tap opens its sheet; the check
|
||||
/// [showProject], the project and section it is in at the end of that line. A tap opens its sheet; the check
|
||||
/// marks it done, which can be undone for a moment.
|
||||
class const TaskTile({
|
||||
required final OpenTask task,
|
||||
|
|
@ -39,12 +39,27 @@ class const TaskTile({
|
|||
for (final label in state.labels) label.id: label.content!,
|
||||
};
|
||||
final project = state.projectOf(content.projectId);
|
||||
final section = switch (content.sectionId) {
|
||||
final id? =>
|
||||
state.sections
|
||||
.where((section) => section.id == id)
|
||||
.firstOrNull
|
||||
?.content,
|
||||
null => null,
|
||||
};
|
||||
Widget meta(IconData icon, String text, Color color) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(text, style: small?.copyWith(color: color)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: small?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final details = [
|
||||
|
|
@ -74,6 +89,38 @@ class const TaskTile({
|
|||
},
|
||||
),
|
||||
];
|
||||
final where = showProject && project != null
|
||||
? Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
[
|
||||
if (project.inbox)
|
||||
l10n.shellTabInbox
|
||||
else
|
||||
project.content!.name,
|
||||
?section?.name,
|
||||
].join(' / '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: small?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
|
||||
size: 14,
|
||||
color: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 4, right: 16),
|
||||
leading: TaskCheck(task: task),
|
||||
|
|
@ -86,7 +133,7 @@ class const TaskTile({
|
|||
)
|
||||
: null,
|
||||
),
|
||||
subtitle: details.isEmpty && content.description.isEmpty
|
||||
subtitle: details.isEmpty && content.description.isEmpty && where == null
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -97,19 +144,27 @@ class const TaskTile({
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (details.isNotEmpty)
|
||||
if (details.isNotEmpty || where != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Wrap(spacing: 12, runSpacing: 2, children: details),
|
||||
// Where the task is ends the line of its date, as its
|
||||
// place, not a detail of it (the developer's, 1 Nov).
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 2,
|
||||
children: details,
|
||||
),
|
||||
),
|
||||
?where,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: showProject && project != null
|
||||
? Text(
|
||||
project.inbox ? l10n.shellTabInbox : project.content!.name,
|
||||
style: small?.copyWith(color: muted),
|
||||
)
|
||||
: null,
|
||||
onTap: () => openTaskSheet(context, task.id),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The frame around Todo's tabs (ADR 0033, D1, D2): halkyon_ui's
|
||||
/// [NavigationFrame], as every Halkyon app's, with Today, Upcoming, Inbox
|
||||
/// and Browse, a bar floating above the foot below medium windows and a rail
|
||||
/// [NavigationFrame], as every Halkyon app's, with Inbox, Today, Upcoming
|
||||
/// and Browse (Today opening), a bar floating above the foot below medium windows and a rail
|
||||
/// with Todo's icon on top from medium on. Every tab keeps its own stack of
|
||||
/// screens. After an update, what's new shows once (halkyon_app's
|
||||
/// [WhatsNewOnUpdate]), where the app is given it.
|
||||
|
|
@ -32,6 +32,11 @@ class const TodoShell({
|
|||
initialLocation: index == shell.currentIndex,
|
||||
),
|
||||
destinations: [
|
||||
(
|
||||
label: l10n.shellTabInbox,
|
||||
icon: const Icon(LucideIcons.inbox),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabToday,
|
||||
icon: const Icon(LucideIcons.calendarCheck),
|
||||
|
|
@ -42,11 +47,6 @@ class const TodoShell({
|
|||
icon: const Icon(LucideIcons.calendarDays),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabInbox,
|
||||
icon: const Icon(LucideIcons.inbox),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabBrowse,
|
||||
// The cog to Settings is atop Browse: the count of accounts
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue