feat(learn): the assistant's field spans the screen, its buttons boxed and still as who answers changes

Following the developer's review of 3 Nov 2026 (ADR 0021, D67):

- Unfolded in the Library, the field spans the screen between its margins,
  as on Home and a studyset: it fell short on the right, by the padding
  around the two folded buttons, which now stands only around those.
- Who answers stands right before the buttons at the right, which no longer
  shift as its name changes (Claude Haiku, Claude Opus): it fills the space
  left, aligned to its end, rather than a share of it.
- Every button of the field and of a conversation's follow-up is boxed, a
  tile a shade above the field with rounded corners, as Claude's apps have
  them; who answers too.
- On a studyset with a licence, the unfolded field takes its usual place
  over the licence's line rather than squeezing in above it; folded, its
  row still keeps clear of the line.

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-03 21:50:00 +01:00
commit 96b1a0fc64
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 209 additions and 64 deletions

View file

@ -317,48 +317,57 @@ class _AssistantFieldState() extends State<AssistantField> {
const shape = RoundedRectangleBorder( const shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(24)), borderRadius: BorderRadius.all(Radius.circular(24)),
); );
// Who answers stands right before the buttons at the right, which stay
// where they are whatever its name (the developer's review of 3 Nov
// 2026: they shifted as it changed).
const gap = SizedBox(width: 6);
final controls = Row( final controls = Row(
children: [ children: [
if (widget.onClose case final close?) if (widget.onClose case final close?) ...[
IconButton( IconButton(
tooltip: l10n.assistantClose, tooltip: l10n.assistantClose,
visualDensity: VisualDensity.compact, style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.x), icon: const Icon(LucideIcons.x, size: 20),
onPressed: close, onPressed: close,
), ),
gap,
],
if (answerer case AssistantAnswerer(:final choice)) if (answerer case AssistantAnswerer(:final choice))
AttachButton( AttachButton(
images: choice.images, images: choice.images,
assistant: choice.name, assistant: choice.name,
onAttached: (part) => setState(() => _attached.add(part)), onAttached: (part) => setState(() => _attached.add(part)),
), ),
const Spacer(), gap,
Flexible( Expanded(
flex: 4, child: Align(
child: _Picker( alignment: AlignmentDirectional.centerEnd,
answerers: answerers, child: _Picker(
current: answerer, answerers: answerers,
effort: switch (answerer) { current: answerer,
AssistantAnswerer(:final choice) effort: switch (answerer) {
when choice.efforts.isNotEmpty && choice.effort != null => AssistantAnswerer(:final choice)
( when choice.efforts.isNotEmpty && choice.effort != null =>
efforts: choice.efforts, (
standard: choice.effort!, efforts: choice.efforts,
chosen: effortFor(choice, askEfforts), standard: choice.effort!,
), chosen: effortFor(choice, askEfforts),
_ => null, ),
}, _ => null,
onPicked: (picked) => setState(() => _picked = picked.key), },
onEffort: (effort) => context.read<DeviceSettingsBloc>().add( onPicked: (picked) => setState(() => _picked = picked.key),
DeviceSettingsEventEffortChosen(answerer.key, effort), onEffort: (effort) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventEffortChosen(answerer.key, effort),
),
), ),
), ),
), ),
if (answerers.any((each) => each is AssistantAnswerer)) if (answerers.any((each) => each is AssistantAnswerer)) ...[
gap,
IconButton( IconButton(
tooltip: l10n.assistantHistory, tooltip: l10n.assistantHistory,
visualDensity: VisualDensity.compact, style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.history), icon: const Icon(LucideIcons.history, size: 20),
onPressed: () => unawaited( onPressed: () => unawaited(
showConversationHistory( showConversationHistory(
context, context,
@ -366,6 +375,8 @@ class _AssistantFieldState() extends State<AssistantField> {
), ),
), ),
), ),
],
gap,
SpeakOrSend( SpeakOrSend(
controller: _question, controller: _question,
canSend: () => canSend: () =>
@ -389,7 +400,7 @@ class _AssistantFieldState() extends State<AssistantField> {
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 6, 6), padding: const EdgeInsets.fromLTRB(6, 4, 6, 6),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -756,23 +767,30 @@ class const _Picker({
], ],
builder: (context, controller, _) => Tooltip( builder: (context, controller, _) => Tooltip(
message: l10n.assistantChoose, message: l10n.assistantChoose,
child: InkWell( child: Material(
borderRadius: BorderRadius.circular(16), color: theme.colorScheme.surfaceContainerHighest,
onTap: () => borderRadius: BorderRadius.circular(12),
controller.isOpen ? controller.close() : controller.open(), clipBehavior: Clip.antiAlias,
child: Padding( child: InkWell(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), onTap: () =>
child: Row( controller.isOpen ? controller.close() : controller.open(),
mainAxisSize: MainAxisSize.min, child: Padding(
children: [ padding: const EdgeInsets.symmetric(
Flexible(child: label), horizontal: 10,
const SizedBox(width: 2), vertical: 10,
Icon( ),
LucideIcons.chevronDown, child: Row(
size: 14, mainAxisSize: MainAxisSize.min,
color: theme.colorScheme.onSurfaceVariant, children: [
), Flexible(child: label),
], const SizedBox(width: 2),
Icon(
LucideIcons.chevronDown,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
), ),
), ),
), ),
@ -983,13 +1001,18 @@ class _AssistantButtonState() extends State<AssistantButton> {
}, },
); );
if (end != null) { if (end != null) {
return Row( // Unfolded, the field alone, as wide as the screen less its margins
crossAxisAlignment: CrossAxisAlignment.end, // (the developer's review of 3 Nov 2026: it fell short on the
children: [ // right); folded, the two buttons at the screen's margins.
button, return _expanded
if (!_expanded) ...[const Spacer(), end], ? button
], : Padding(
); padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [button, const Spacer(), end],
),
);
} }
if (beside == null || _expanded) { if (beside == null || _expanded) {
return button; return button;

View file

@ -10,7 +10,20 @@ import 'package:material_ui/material_ui.dart';
// What the field asking an assistant and a conversation's follow-up share // What the field asking an assistant and a conversation's follow-up share
// below their text, as Claude's apps have it (decided 2026-11-02, ADR 0021, // below their text, as Claude's apps have it (decided 2026-11-02, ADR 0021,
// D67): + attaching files, and the microphone while nothing is written, // D67): + attaching files, and the microphone while nothing is written,
// which gives way to the button that sends. // which gives way to the button that sends. Boxed as Claude's are, each on
// a tile of its own (the developer's review of 3 Nov 2026).
/// The look of a question's buttons, as Claude's apps box theirs: a tile a
/// shade above the field's, its corners rounded.
ButtonStyle boxedButtonStyle(BuildContext context) => IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
fixedSize: const Size.square(40),
minimumSize: const Size.square(40),
padding: EdgeInsets.zero,
);
/// The + below a question's field: picks files to go along, read on the /// The + below a question's field: picks files to go along, read on the
/// device ([AssistantAttachments]), images only where the assistant /// device ([AssistantAttachments]), images only where the assistant
@ -61,8 +74,8 @@ class const AttachButton({
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return IconButton( return IconButton(
tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile, tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile,
visualDensity: VisualDensity.compact, style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus, size: 20),
onPressed: () => unawaited(_attach(context)), onPressed: () => unawaited(_attach(context)),
); );
} }
@ -205,10 +218,11 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
children: [ children: [
IconButton( IconButton(
tooltip: l10n.assistantSpeak, tooltip: l10n.assistantSpeak,
icon: const Icon(LucideIcons.mic), style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.mic, size: 20),
onPressed: () => unawaited(_listen()), onPressed: () => unawaited(_listen()),
), ),
if (canSend) send, if (canSend) ...[const SizedBox(width: 6), send],
], ],
); );
}, },

View file

@ -183,12 +183,9 @@ class _LibraryState() extends State<_Library> {
// Searching the web at the lower left, Create studyset at the lower // Searching the web at the lower left, Create studyset at the lower
// right (decided 2026-10-16, as Vaia has them). // right (decided 2026-10-16, as Vaia has them).
fab: switch ((asks, create)) { fab: switch ((asks, create)) {
(true, final create?) => Padding( (true, final create?) => AssistantButton(
padding: const EdgeInsets.symmetric(horizontal: 16), topic: const AssistantTopicLearning(),
child: AssistantButton( end: create,
topic: const AssistantTopicLearning(),
end: create,
),
), ),
(true, null) => const AssistantButton( (true, null) => const AssistantButton(
topic: AssistantTopicLearning(), topic: AssistantTopicLearning(),

View file

@ -257,6 +257,13 @@ class const _StudysetView({
} }
class _StudysetViewState() extends State<_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. /// How far the floating bar rises above the licence line.
static const _licenceClearance = 40.0; static const _licenceClearance = 40.0;
@ -320,6 +327,7 @@ class _StudysetViewState() extends State<_StudysetView> {
title: studyset.content.title, title: studyset.content.title,
description: studyset.content.description, description: studyset.content.description,
), ),
onUnfolded: _onUnfolded,
) )
: null) : null)
// Asking stands at the left of the bar's row, smaller (decided // Asking stands at the left of the bar's row, smaller (decided
@ -333,6 +341,7 @@ class _StudysetViewState() extends State<_StudysetView> {
), ),
small: true, small: true,
beside: _StudyBar(studyset: studyset, addMaterial: _addMaterial), beside: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
onUnfolded: _onUnfolded,
), ),
builder: (context, fab) => TopGradient( builder: (context, fab) => TopGradient(
color: color, color: color,
@ -473,7 +482,7 @@ class _StudysetViewState() extends State<_StudysetView> {
// Above the licence line, which it would cover otherwise. // Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: wide floatingActionButtonLocation: wide
? FloatingActionButtonLocation.startFloat ? FloatingActionButtonLocation.startFloat
: LicenseFooter.shows(studyset.content) : !_unfolded && LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance) ? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat, : FloatingActionButtonLocation.centerFloat,
body: Column( body: Column(

View file

@ -465,6 +465,53 @@ void main() {
debugDefaultTargetPlatformOverride = null; debugDefaultTargetPlatformOverride = null;
}); });
for (final (where, location) in [
('Home', RoutePaths.home),
('the Library', RoutePaths.library),
('a studyset', RoutePaths.studyset('japanese')),
]) {
testWidgets('unfolded on $where, the field spans the width between the '
"screen's margins, as wide on the right as on the left (the "
"developer's review of 3 Nov 2026)", (tester) async {
await showApp(tester, location: location);
await unfold(tester);
final field = tester.getRect(find.byType(AssistantField));
expect(field.left, closeTo(16, 0.01));
expect(field.right, closeTo(400 - 16, 0.01));
});
}
testWidgets('on a studyset with a licence, the folded button keeps clear '
'of its line; unfolded, the field takes its usual place over it rather '
"than squeezing in above it (the developer's review of 3 Nov 2026)", (
tester,
) async {
studysets.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
license: 'CC-BY-SA-4.0',
licenseHolder: 'Tae Kim',
licenseYear: 2026,
),
),
);
await showApp(tester, location: RoutePaths.studyset('japanese'));
final licence = find.textContaining('Tae Kim', findRichText: true);
final line = tester.getRect(licence);
expect(
tester.getRect(find.byTooltip('Search the web')).bottom,
lessThan(line.top),
reason: 'folded, clear of the line',
);
await unfold(tester);
final field = tester.getRect(find.byType(AssistantField));
expect(field.bottom, greaterThan(tester.getRect(licence).top));
});
testWidgets("a studyset's name is never offered to go into a web search", ( testWidgets("a studyset's name is never offered to go into a web search", (
tester, tester,
) async { ) async {

View file

@ -316,6 +316,60 @@ void main() {
); );
}); });
testWidgets('the buttons right of who answers stay where they are '
'whatever its name, and every button of the field is boxed, as '
"Claude's (the developer's review of 3 Nov 2026)", (tester) async {
assistants.offer = AssistantsOffer(
assistants: [
_claude(),
OfferedAssistant(
id: 'opus',
name: 'Claude Opus, thinking longer',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
agreedAt: _agreed,
),
],
);
await showApp(tester);
Rect at(String tooltip) => tester.getRect(
find.descendant(
of: find.byType(AssistantField),
matching: find.byTooltip(tooltip),
),
);
final history = at('Conversations');
final speak = at('Speak your question');
final field = tester.getRect(find.byType(AssistantField));
expect(field.right - speak.right, lessThan(16), reason: 'at the right');
await openPicker(tester);
await tester.tap(find.text('Claude Opus, thinking longer').last);
await tester.pumpAndSettle();
expect(find.text('Ask Claude Opus, thinking longer…'), findsOneWidget);
expect(at('Conversations'), history);
expect(at('Speak your question'), speak);
for (final icon in [
LucideIcons.plus,
LucideIcons.history,
LucideIcons.mic,
]) {
final button = tester.widget<IconButton>(
find.descendant(
of: find.byType(AssistantField),
matching: find.widgetWithIcon(IconButton, icon),
),
);
expect(
button.style?.backgroundColor?.resolve({}),
isNotNull,
reason: '$icon boxed',
);
}
});
testWidgets("an answer's Copy stands beneath it, its icon in line with " testWidgets("an answer's Copy stands beneath it, its icon in line with "
"the answer's text", (tester) async { "the answer's text", (tester) async {
await showApp(tester); await showApp(tester);
@ -750,7 +804,8 @@ void main() {
await unfold(tester); await unfold(tester);
await choose(tester, ['Studyset: Diary']); await choose(tester, ['Studyset: Diary']);
await ask(tester, 'What is this about?'); // Short: the test's wide font would hang the text's handle over Ask.
await ask(tester, 'About what?');
expect(find.text(warning), findsOneWidget); expect(find.text(warning), findsOneWidget);
expect(find.textContaining('to api.anthropic.com'), findsOneWidget); expect(find.textContaining('to api.anthropic.com'), findsOneWidget);
@ -758,7 +813,7 @@ void main() {
await tester.tap(find.text('Cancel')); await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(assistants.asked, isEmpty); expect(assistants.asked, isEmpty);
expect(find.text('What is this about?'), findsOneWidget); expect(find.text('About what?'), findsOneWidget);
assistants.answers.add(_says('Your diary.')); assistants.answers.add(_says('Your diary.'));
await tester.tap( await tester.tap(