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:
parent
f45ed98d52
commit
bc4953aaf8
6 changed files with 255 additions and 47 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue