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
4 changed files with 209 additions and 64 deletions

View file

@ -317,48 +317,57 @@ class _AssistantFieldState() extends State<AssistantField> {
const shape = RoundedRectangleBorder(
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(
children: [
if (widget.onClose case final close?)
if (widget.onClose case final close?) ...[
IconButton(
tooltip: l10n.assistantClose,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.x),
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.x, size: 20),
onPressed: close,
),
gap,
],
if (answerer case AssistantAnswerer(:final choice))
AttachButton(
images: choice.images,
assistant: choice.name,
onAttached: (part) => setState(() => _attached.add(part)),
),
const Spacer(),
Flexible(
flex: 4,
child: _Picker(
answerers: answerers,
current: answerer,
effort: switch (answerer) {
AssistantAnswerer(:final choice)
when choice.efforts.isNotEmpty && choice.effort != null =>
(
efforts: choice.efforts,
standard: choice.effort!,
chosen: effortFor(choice, askEfforts),
),
_ => null,
},
onPicked: (picked) => setState(() => _picked = picked.key),
onEffort: (effort) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventEffortChosen(answerer.key, effort),
gap,
Expanded(
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: _Picker(
answerers: answerers,
current: answerer,
effort: switch (answerer) {
AssistantAnswerer(:final choice)
when choice.efforts.isNotEmpty && choice.effort != null =>
(
efforts: choice.efforts,
standard: choice.effort!,
chosen: effortFor(choice, askEfforts),
),
_ => null,
},
onPicked: (picked) => setState(() => _picked = picked.key),
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(
tooltip: l10n.assistantHistory,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.history),
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.history, size: 20),
onPressed: () => unawaited(
showConversationHistory(
context,
@ -366,6 +375,8 @@ class _AssistantFieldState() extends State<AssistantField> {
),
),
),
],
gap,
SpeakOrSend(
controller: _question,
canSend: () =>
@ -389,7 +400,7 @@ class _AssistantFieldState() extends State<AssistantField> {
color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 6, 6),
padding: const EdgeInsets.fromLTRB(6, 4, 6, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -756,23 +767,30 @@ class const _Picker({
],
builder: (context, controller, _) => Tooltip(
message: l10n.assistantChoose,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () =>
controller.isOpen ? controller.close() : controller.open(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: label),
const SizedBox(width: 2),
Icon(
LucideIcons.chevronDown,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
controller.isOpen ? controller.close() : controller.open(),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
child: Row(
mainAxisSize: MainAxisSize.min,
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) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
button,
if (!_expanded) ...[const Spacer(), end],
],
);
// 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],
),
);
}
if (beside == null || _expanded) {
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
// 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,
// 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
/// device ([AssistantAttachments]), images only where the assistant
@ -61,8 +74,8 @@ class const AttachButton({
final l10n = AppLocalizations.of(context);
return IconButton(
tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.plus),
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.plus, size: 20),
onPressed: () => unawaited(_attach(context)),
);
}
@ -205,10 +218,11 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
children: [
IconButton(
tooltip: l10n.assistantSpeak,
icon: const Icon(LucideIcons.mic),
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.mic, size: 20),
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
// right (decided 2026-10-16, as Vaia has them).
fab: switch ((asks, create)) {
(true, final create?) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AssistantButton(
topic: const AssistantTopicLearning(),
end: create,
),
(true, final create?) => AssistantButton(
topic: const AssistantTopicLearning(),
end: create,
),
(true, null) => const AssistantButton(
topic: AssistantTopicLearning(),

View file

@ -257,6 +257,13 @@ 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;
@ -320,6 +327,7 @@ 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
@ -333,6 +341,7 @@ class _StudysetViewState() extends State<_StudysetView> {
),
small: true,
beside: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
onUnfolded: _onUnfolded,
),
builder: (context, fab) => TopGradient(
color: color,
@ -473,7 +482,7 @@ class _StudysetViewState() extends State<_StudysetView> {
// Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: wide
? FloatingActionButtonLocation.startFloat
: LicenseFooter.shows(studyset.content)
: !_unfolded && LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
body: Column(