fix(learn,ui): the open assistant field holds still: no flicker, no sliding away, no resizing under the keyboard (ADR 0021, D69)

Inside a studyset the field flickered as it opened and closed: the
floating location switched between a raised centre and the centre,
which Scaffold animates by scaling the button out and in. It keeps one
location now, raised above the licence line by padding. While open it
no longer slides away as the studyset or the Library scrolls
(ScrollHidingFab.pinned, new), and the keyboard covers Home, the
Library, a studyset and a session rather than shrinking them; the field
lifts itself by as much of the keyboard as reaches above it.
NavigationFrame no longer resizes for the keyboard itself, leaving it
to each tab's own scaffold, which still does by default (Todo's,
Train's and Meditate's tests pass unchanged).

Whats-New: Inside a studyset, the question field opens without flickering, stays while you scroll, and the screen no longer jumps when the keyboard comes.
Whats-New-De: In einem Lernset öffnet sich das Fragefeld ohne Flackern, bleibt beim Scrollen stehen, und der Bildschirm springt nicht mehr, wenn die Tastatur kommt.

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-04 21:15:48 +01:00
commit bc4953aaf8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 255 additions and 47 deletions

View file

@ -40,6 +40,17 @@ bool assistantOn(BuildContext context) {
return web || assistants;
}
/// Whether the field is unfolded from its button (D69): the screen
/// hosting it then keeps its size as the keyboard comes, the field lifting
/// itself above it ([AssistantButton]), and keeps it in view while its
/// content scrolls.
bool composerOpen(BuildContext context) {
final open = context.select<ComposerCubit, bool>(
(composer) => composer.state.open,
);
return open && assistantOn(context);
}
/// Who may answer the field's questions here: the web search where it is
/// on, then the assistants offered.
List<Answerer> answerersOf(BuildContext context) {
@ -941,6 +952,28 @@ class _AssistantButtonState() extends State<AssistantButton> {
void _fold() => context.read<ComposerCubit>().fold();
/// The unfolded field, to measure where it stands.
final _field = GlobalKey();
/// How far below the field the window ends, the keyboard aside: what the
/// keyboard covers before it reaches the field.
var _below = 0.0;
/// Measures [_below] once laid out, anew when it moved.
void _measureBelow() => WidgetsBinding.instance.addPostFrameCallback((_) {
final box = _field.currentContext?.findRenderObject();
if (!mounted || box is! RenderBox || !box.hasSize) {
return;
}
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final lift = (keyboard - _below).clamp(0, double.infinity);
final bottom = box.localToGlobal(Offset(0, box.size.height)).dy;
final below = MediaQuery.sizeOf(context).height - bottom - lift;
if ((below - _below).abs() > 0.5) {
setState(() => _below = below.clamp(0, double.infinity));
}
});
void _unfold() => context.read<ComposerCubit>().unfold();
/// Whether this button's screen is the one showing, so that it is the
@ -1078,21 +1111,32 @@ class _AssistantButtonState() extends State<AssistantButton> {
);
},
);
if (expanded) {
// The screen keeps its size under the keyboard (D69): the field
// rises above it itself, by as much as the keyboard reaches above
// where it stands.
_measureBelow();
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(
bottom: (keyboard - _below).clamp(0, double.infinity),
),
child: KeyedSubtree(key: _field, child: button),
);
}
if (end != null) {
// Unfolded, the field alone, as wide as the screen less its margins
// (the developer's review of 3 Nov 2026: it fell short on the
// right); folded, the two buttons at the screen's margins.
return expanded
? button
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [button, const Spacer(), end],
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [button, const Spacer(), end],
),
);
}
if (beside == null || expanded) {
if (beside == null) {
return button;
}
return Padding(

View file

@ -102,6 +102,9 @@ class _HomeState() extends State<_Home> {
DateTime? today,
) => Scaffold(
backgroundColor: Colors.transparent,
// The keyboard covers the screen rather than shrinking it while the
// assistant's field is open, which lifts itself above it (D69).
resizeToAvoidBottomInset: !composerOpen(context),
// The sections scroll on beneath the Ask field and the navigation bar.
// No field, no slot: an empty one would take the room the body keeps
// for the navigation bar.

View file

@ -677,6 +677,9 @@ class const _Showing(
background: theme.colorScheme.surfaceContainerLow,
child: Scaffold(
backgroundColor: Colors.transparent,
// The keyboard covers the screen rather than shrinking it while the
// assistant's field is open, which lifts itself above it (D69).
resizeToAvoidBottomInset: !composerOpen(context),
body: Stack(
children: [
SafeArea(

View file

@ -180,6 +180,8 @@ class _LibraryState() extends State<_Library> {
);
final asks = assistantOn(context);
return ScrollHidingFab(
// Never sliding away while it is being written in (D69).
pinned: composerOpen(context),
// Searching the web at the lower left, Create studyset at the lower
// right (decided 2026-10-16, as Vaia has them).
fab: switch ((asks, create)) {
@ -198,6 +200,9 @@ class _LibraryState() extends State<_Library> {
child: Scaffold(
// The cards stand out from it by their shadow.
backgroundColor: Colors.transparent,
// The keyboard covers the screen rather than shrinking it while the
// assistant's field is open, which lifts itself above it (D69).
resizeToAvoidBottomInset: !composerOpen(context),
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),

View file

@ -257,13 +257,6 @@ class const _StudysetView({
}
class _StudysetViewState() extends State<_StudysetView> {
/// Whether the assistant's button is unfolded into its field, which then
/// takes its usual place over the licence's line rather than squeezing
/// in above it (the developer's review of 3 Nov 2026).
var _unfolded = false;
void _onUnfolded(bool unfolded) => setState(() => _unfolded = unfolded);
/// How far the floating bar rises above the licence line.
static const _licenceClearance = 40.0;
@ -316,7 +309,17 @@ class _StudysetViewState() extends State<_StudysetView> {
final studyset = widget.studyset;
final theme = Theme.of(context);
final wide = context.windowSize.isAtLeastExpanded;
final open = composerOpen(context);
// Above the licence line, which it would cover otherwise; unfolded, the
// field takes its usual place over it. Moved by padding rather than by
// another location of the scaffold's, which the scaffold would animate
// by scaling the button out and in (the flicker of 4 Nov 2026, D69).
final raise = !wide && !open && LicenseFooter.shows(studyset.content)
? _licenceClearance
: 0.0;
return ScrollHidingFab(
// Never sliding away while it is being written in (D69).
pinned: open,
// From expanded windows on, Study and Add material are in the app
// bar, and the web search floats alone.
fab: wide
@ -327,21 +330,25 @@ class _StudysetViewState() extends State<_StudysetView> {
title: studyset.content.title,
description: studyset.content.description,
),
onUnfolded: _onUnfolded,
)
: null)
// Asking stands at the left of the bar's row, smaller (decided
// 2026-10-16, as Vaia has it; in the row since 2026-11-01);
// unfolded, its field takes the bar's place.
: AssistantButton(
topic: AssistantTopicStudyset(
id: studyset.id,
title: studyset.content.title,
description: studyset.content.description,
: Padding(
padding: EdgeInsets.only(bottom: raise),
child: AssistantButton(
topic: AssistantTopicStudyset(
id: studyset.id,
title: studyset.content.title,
description: studyset.content.description,
),
small: true,
beside: _StudyBar(
studyset: studyset,
addMaterial: _addMaterial,
),
),
small: true,
beside: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
onUnfolded: _onUnfolded,
),
builder: (context, fab) => TopGradient(
color: color,
@ -479,12 +486,13 @@ class _StudysetViewState() extends State<_StudysetView> {
],
),
floatingActionButton: fab,
// Above the licence line, which it would cover otherwise.
// One location whatever the field does (D69).
floatingActionButtonLocation: wide
? FloatingActionButtonLocation.startFloat
: !_unfolded && LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
// The keyboard covers the screen rather than shrinking it while the
// field is open, which lifts itself above it (D69).
resizeToAvoidBottomInset: !composerOpen(context),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -670,18 +678,6 @@ class _StudysetViewState() extends State<_StudysetView> {
}
}
/// [FloatingActionButtonLocation.centerFloat], [lift] higher.
class const _RaisedCenterFloat(final double lift)
extends FloatingActionButtonLocation {
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
final base = FloatingActionButtonLocation.centerFloat.getOffset(
scaffoldGeometry,
);
return Offset(base.dx, base.dy - lift);
}
}
/// The bibliography, as an outlined round button at the foot of the topics.
class const _BibliographyButton({required final Studyset studyset})
extends StatelessWidget {

View file

@ -769,4 +769,87 @@ void main() {
expect(written(tester), '');
});
});
group("a studyset's field (D69)", () {
setUp(
() => studysets.receiveRemoteChange(
'licensed',
encodeStudysetPayload(
StudysetContent(
title: 'Licensed',
description: RichContent.empty(),
license: 'CC-BY-SA-4.0',
licenseHolder: 'raisondetredev',
),
),
position: 'a2',
),
);
Future<void> showStudyset(WidgetTester tester) async {
await showApp(tester, location: RoutePaths.studyset('licensed'));
}
testWidgets('unfolds in place, the button never scaled out and in as '
'the scaffold does on a change of location (the flicker of 4 Nov '
'2026)', (tester) async {
await showStudyset(tester);
final button = tester.element(find.byType(AssistantButton));
await tester.tap(find.byTooltip('Search the web'));
for (var frame = 0; frame < 10; frame++) {
await tester.pump(const Duration(milliseconds: 16));
for (final scale in tester.widgetList<ScaleTransition>(
find.ancestor(
of: find.byType(AssistantButton),
matching: find.byType(ScaleTransition),
),
)) {
expect(scale.scale.value, 1, reason: 'frame $frame');
}
}
await tester.pumpAndSettle();
expect(find.byType(AssistantField), findsOneWidget);
expect(
tester.element(find.byType(AssistantButton)),
same(button),
reason: 'the same button, unfolded',
);
});
testWidgets('open, it stays in view while the studyset scrolls, and the '
'keyboard leaves the screen its size, the field rising above it', (
tester,
) async {
await showStudyset(tester);
final body = find
.descendant(
of: find.byType(Scaffold).last,
matching: find.byType(Column),
)
.first;
final size = tester.getSize(body);
await tester.tap(find.byTooltip('Search the web'));
await tester.pumpAndSettle();
final field = tester.getRect(find.byType(AssistantField));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getSize(body), size, reason: 'not resized');
expect(
tester.getRect(find.byType(AssistantField)).bottom,
lessThanOrEqualTo(900 - 300),
reason: 'above the keyboard',
);
expect(
tester.getRect(find.byType(AssistantField)).bottom,
lessThan(field.bottom),
);
await tester.drag(body, const Offset(0, -300), warnIfMissed: false);
await tester.pumpAndSettle();
expect(find.byType(AssistantField).hitTestable(), findsOneWidget);
});
});
}

View file

@ -531,6 +531,15 @@ assistants' effort work:
Only the button of the screen at the router's location unfolds, so a tab kept alive
behind another holds no second field. Sending empties it everywhere. How far an
assistant looks stays each field's own, starting at only what is chosen (D50).
- **Open, the field holds still** (the developer's review of 4 Nov 2026, inside a
studyset): it unfolds in place, raised above the licence line by padding rather than
by another location of the scaffold's, which the scaffold animates by scaling the
button out and in (the flicker); it never slides away as the content scrolls
(`ScrollHidingFab.pinned`); and the keyboard covers the screen rather than shrinking
it (`resizeToAvoidBottomInset` off on Home, the Library, a studyset and studying while
it is open), the field lifting itself by as much of the keyboard as reaches above it.
halkyon_ui's `NavigationFrame` leaves the keyboard to each tab's own scaffold for
this, every app's screens resizing as before by default.
As built on 26 Oct, for v0.22.0 (build 25, which the server needs: `minimumAppBuild` 25,
as older apps read limits per grant), D57–D63 read so where they left something open:

View file

@ -152,6 +152,11 @@ class _NavigationFrameState() extends State<NavigationFrame> {
// it, as iOS 26's tab bar floats over content; their lists end
// with room for it (the body's bottom padding says how much).
extendBody: !widget.hidesNavigationBar,
// The keyboard is each screen's own to make way for, its own
// scaffold resizing as it does by default: a screen with a field
// floating over it keeps its size and lifts the field instead
// (Halkyon Learn's ADR 0021, D69).
resizeToAvoidBottomInset: false,
body: widget.child,
// Without the bar, a strip as high as the system's own bar, in
// the screen's background, keeps every screen inside clear of

View file

@ -7,7 +7,8 @@ import 'package:material_ui/material_ui.dart';
/// floating action button); the scrolling in that screen moves it.
///
/// While hidden, the button can't be tapped through its fading ghost. A list
/// short enough not to scroll keeps it in view: it covers nothing there.
/// short enough not to scroll keeps it in view: it covers nothing there, and
/// so does [pinned].
class const ScrollHidingFab({
/// The button; none when null, as in windows that put the action in the
/// app bar instead.
@ -15,6 +16,10 @@ class const ScrollHidingFab({
/// The screen, given the button to place.
required final Widget Function(BuildContext context, Widget? fab) builder,
/// Whether the button stays whatever the scrolling, as while it is a field
/// being written in, which mustn't slide away under the person's typing.
final bool pinned = false,
super.key,
}) extends StatefulWidget {
@override
@ -27,7 +32,7 @@ class _ScrollHidingFabState() extends State<ScrollHidingFab> {
bool _onScroll(UserScrollNotification notification) {
// Only the screen's own vertical scrolling: a sideways swipe between
// tabs moves nothing.
if (notification.metrics.axis != Axis.vertical) {
if (notification.metrics.axis != Axis.vertical || widget.pinned) {
return false;
}
final visible = switch (notification.direction) {
@ -46,6 +51,7 @@ class _ScrollHidingFabState() extends State<ScrollHidingFab> {
@override
Widget build(BuildContext context) {
final fab = widget.fab;
final visible = _visible || widget.pinned;
return NotificationListener<UserScrollNotification>(
onNotification: _onScroll,
child: widget.builder(
@ -57,11 +63,11 @@ class _ScrollHidingFabState() extends State<ScrollHidingFab> {
curve: Curves.easeOut,
// Twice its own height clears the window's edge whatever
// the button's size.
offset: _visible ? Offset.zero : const Offset(0, 2),
offset: visible ? Offset.zero : const Offset(0, 2),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _visible ? 1 : 0,
child: IgnorePointer(ignoring: !_visible, child: fab),
opacity: visible ? 1 : 0,
child: IgnorePointer(ignoring: !visible, child: fab),
),
),
),

View file

@ -196,4 +196,42 @@ void main() {
}
}
});
testWidgets("the keyboard leaves the frame's tab its size: the tab's own "
'screen decides whether it makes way, as one with a field floating '
"over it doesn't", (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Widget frame({required bool screenResizes}) => testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Scaffold(
resizeToAvoidBottomInset: screenResizes,
body: const SizedBox.expand(key: Key('tab')),
),
),
);
await tester.pumpWidget(frame(screenResizes: false));
final before = tester.getSize(find.byKey(const Key('tab')));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pump();
expect(tester.getSize(find.byKey(const Key('tab'))), before);
await tester.pumpWidget(frame(screenResizes: true));
expect(
tester.getSize(find.byKey(const Key('tab'))).height,
lessThan(before.height),
reason: 'a screen that resizes still makes way',
);
});
}

View file

@ -4,13 +4,18 @@ import 'package:material_ui/material_ui.dart';
void main() {
/// A list of [lines] with a button that hides while reading onwards.
Future<void> show(WidgetTester tester, {int lines = 100}) async {
Future<void> show(
WidgetTester tester, {
int lines = 100,
bool pinned = false,
}) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: ScrollHidingFab(
pinned: pinned,
builder: (context, fab) => Scaffold(
floatingActionButton: fab,
body: ListView(
@ -55,4 +60,15 @@ void main() {
expect(fab(), findsOneWidget);
});
testWidgets('stays while pinned, as a field being written in', (
tester,
) async {
await show(tester, pinned: true);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fab(), findsOneWidget);
});
}