feat(learn): the assistant's conversation a dialog on the desktop and in expanded windows (Todo's ADR 0033, D11)
- halkyon_ui's sheetShowsAsDialog says whether showAdaptiveSheet shows a dialog here, for a sheet of its own that can't go through it. - Learn's conversation, a half-height sheet dragged up, asks it: where sheets are dialogs it is a dialog up to 720 wide and as tall as the window, with no handle to drag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c83f7c46ff
commit
4898d0a04a
6 changed files with 145 additions and 23 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 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.
|
||||
- **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 of its own asks `sheetShowsAsDialog`, as the assistant's conversation does, a dialog up to 720 wide and as tall as the window there instead of its half-height sheet dragged up); 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
/// Opens a conversation with [choice] in [scope] for [task] in a sheet over
|
||||
/// the screen it is asked on (ADR 0021, D47): at half height, dragged up to
|
||||
/// the full, with follow-up questions at its foot. Asks [question] with
|
||||
/// the full, with follow-up questions at its foot; where halkyon_ui's
|
||||
/// sheets are dialogs (from expanded windows on, and on the desktop), a
|
||||
/// dialog as tall as the window allows. Asks [question] with
|
||||
/// [chosen] at once where given, a new conversation called [title] where
|
||||
/// given, or continues the kept conversation [conversationId]. [choice]
|
||||
/// null shows a kept conversation whose assistant has gone, which can't go
|
||||
|
|
@ -66,15 +68,65 @@ Future<void> showConversation(
|
|||
await cubit.ask(question, chosen);
|
||||
}
|
||||
}());
|
||||
// Closing the sheet closes the cubit, which stops an answer on its way.
|
||||
Widget provided(Widget child) =>
|
||||
BlocProvider(create: (context) => cubit, child: child);
|
||||
// A dialog where halkyon_ui's sheets are (Todo's ADR 0033, D11): from
|
||||
// expanded windows on, and on the desktop at every width.
|
||||
if (sheetShowsAsDialog(context)) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => provided(
|
||||
_ConversationDialog(name: name, scope: scope, choice: choice),
|
||||
),
|
||||
);
|
||||
}
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => BlocProvider(
|
||||
// Closing the sheet closes the cubit, which stops an answer on its
|
||||
// way.
|
||||
create: (context) => cubit,
|
||||
child: _ConversationSheet(name: name, scope: scope, choice: choice),
|
||||
builder: (context) =>
|
||||
provided(_ConversationSheet(name: name, scope: scope, choice: choice)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The conversation as a dialog, as tall as the window lets it, up to a
|
||||
/// reading width: nothing to drag.
|
||||
class const _ConversationDialog({
|
||||
required final String name,
|
||||
required final AssistantScope scope,
|
||||
required final AssistantChoice? choice,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_ConversationDialog> createState() => _ConversationDialogState();
|
||||
}
|
||||
|
||||
class _ConversationDialogState() extends State<_ConversationDialog> {
|
||||
final _controller = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
insetPadding: context.windowSize.isAtLeastMedium
|
||||
? const EdgeInsets.symmetric(horizontal: 40, vertical: 32)
|
||||
: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: SizedBox.expand(
|
||||
child: _ConversationView(
|
||||
controller: _controller,
|
||||
name: widget.name,
|
||||
scope: widget.scope,
|
||||
choice: widget.choice,
|
||||
handle: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -99,6 +151,7 @@ class const _ConversationSheet({
|
|||
name: name,
|
||||
scope: scope,
|
||||
choice: choice,
|
||||
handle: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -112,6 +165,9 @@ class const _ConversationView({
|
|||
/// Who answers; null for an assistant no longer offered, which can't go
|
||||
/// on.
|
||||
required final AssistantChoice? choice,
|
||||
|
||||
/// Whether a handle to drag it by sits on top: in a bottom sheet.
|
||||
required final bool handle,
|
||||
}) extends StatelessWidget {
|
||||
/// How close to its end the conversation counts as followed: closer, it
|
||||
/// follows the answer as it grows.
|
||||
|
|
@ -141,7 +197,7 @@ class const _ConversationView({
|
|||
controller: controller,
|
||||
slivers: [
|
||||
PinnedHeaderSliver(
|
||||
child: _Header(name: name, scope: scope),
|
||||
child: _Header(name: name, scope: scope, handle: handle),
|
||||
),
|
||||
if (!state.keeps)
|
||||
SliverToBoxAdapter(
|
||||
|
|
@ -202,11 +258,12 @@ class const _ConversationView({
|
|||
}
|
||||
}
|
||||
|
||||
/// The handle to drag the sheet by, who answers and how far it looks, and
|
||||
/// Close.
|
||||
/// The handle to drag the sheet by (where [handle]), who answers and how
|
||||
/// far it looks, and Close.
|
||||
class const _Header({
|
||||
required final String name,
|
||||
required final AssistantScope scope,
|
||||
required final bool handle,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -217,19 +274,20 @@ class const _Header({
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
if (handle)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
|
@ -265,6 +265,30 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('on the desktop the conversation opens in a dialog, however '
|
||||
"narrow the window, with no handle to drag (Todo's ADR 0033, D11)", (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await showApp(tester);
|
||||
assistants.answers.add(_says('Because it joins clauses.'));
|
||||
|
||||
await ask(tester, 'Why て-form?');
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.byType(DraggableScrollableSheet), findsNothing);
|
||||
expect(
|
||||
find.text('Because it joins clauses.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('an assistant not agreed to asks for agreement first, sending '
|
||||
'nothing (D40)', (tester) async {
|
||||
assistants.offer = AssistantsOffer(assistants: [_claude(agreed: false)]);
|
||||
|
|
|
|||
|
|
@ -170,7 +170,10 @@ alternative was put to them, the choice is theirs.
|
|||
(Material's term) on Linux, macOS and Windows however narrow the window, and on phones
|
||||
and tablets a bottom sheet below expanded windows as before, so Learn and Train follow.
|
||||
A bottom sheet is a touch surface; rising from the foot of a desktop window it reads
|
||||
as a phone's screen. In a narrow window the dialog keeps a smaller margin.
|
||||
as a phone's screen. In a narrow window the dialog keeps a smaller margin. A sheet
|
||||
that can't go through it asks `sheetShowsAsDialog` and follows the same rule: Learn's
|
||||
conversation is a dialog as tall as the window there, nothing to drag. Train's route
|
||||
import still opens its bottom sheet everywhere, until Train 0.1.1 is out.
|
||||
- **D12 The tabs are Inbox, Today, Upcoming, Browse; Today still opens** (amends D2's
|
||||
order, not what opens).
|
||||
- **D13 A task's sheet** names where the task is on top (its project, an icon once tasks
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
required WidgetBuilder builder,
|
||||
bool swipeable = true,
|
||||
}) {
|
||||
if (!context.windowSize.isAtLeastExpanded && !_onDesktop) {
|
||||
if (!sheetShowsAsDialog(context)) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
|
|
@ -63,6 +63,13 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether [showAdaptiveSheet] shows a sheet as a dialog here: from
|
||||
/// expanded windows on, and on the desktop at every width. For a sheet of
|
||||
/// its own that can't go through it (one dragged to its height, as Learn's
|
||||
/// conversation), so that it follows the same rule.
|
||||
bool sheetShowsAsDialog(BuildContext context) =>
|
||||
context.windowSize.isAtLeastExpanded || _onDesktop;
|
||||
|
||||
/// Whether the app runs on the desktop, where sheets are dialogs at every
|
||||
/// width.
|
||||
bool get _onDesktop => switch (defaultTargetPlatform) {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,36 @@ void main() {
|
|||
expect(find.byType(BottomSheet), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sheetShowsAsDialog says which a sheet of its own will be: '
|
||||
'a dialog from expanded windows on, and on the desktop at every width', (
|
||||
tester,
|
||||
) async {
|
||||
Future<bool> at(double width) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
late bool shows;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
shows = sheetShowsAsDialog(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return shows;
|
||||
}
|
||||
|
||||
addTearDown(tester.view.reset);
|
||||
expect(await at(400), isFalse);
|
||||
expect(await at(840), isTrue);
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
expect(await at(400), isTrue);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
for (final platform in [
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.macOS,
|
||||
|
|
|
|||
Loading…
Reference in a new issue