feat(learn,app): the field's row as one, its model by name in its provider's colour, the microphone dictating (ADR 0021, D69)
The developer's review of 4 Nov 2026. Below the question, × and + stand
at the start, who answers is centred and the conversations and the
microphone stand at the end, every button the same rounded square, the
microphone listening and Send too. The row above the text ("Only what I
choose · 2 parts") goes, as +'s sheet offers the same choice; a web
search gets + with that row alone. Who answers shows its model's name
alone ('Haiku 4.5', AssistantProvider.modelName: Anthropic's without
"Claude", others as they are), outlined in its provider's colour (D60).
The microphone is halkyon_app's Dictation: what is said goes where the
cursor stands, and it listens again after each pause until stopped. It
gives way to Send once anything could be sent, as Claude's field does.
Whats-New: The question's buttons are in one row: close and + on the left, who answers in the middle, Conversations and the microphone on the right.
Whats-New: What goes along is chosen under +, for the web search too.
Whats-New: Who answers shows the model's name, in its provider's colour.
Whats-New: The microphone keeps listening after a pause, writing where the cursor is, until you stop it.
Whats-New-De: Die Knöpfe der Frage stehen in einer Reihe: Schließen und + links, wer antwortet in der Mitte, Unterhaltungen und Mikrofon rechts.
Whats-New-De: Was mitgeht, wählst du unter +, auch für die Websuche.
Whats-New-De: Wer antwortet, zeigt den Namen des Modells, in der Farbe seines Anbieters.
Whats-New-De: Das Mikrofon hört nach einer Pause weiter zu und schreibt, wo der Cursor steht, bis du es stoppst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2d27451cb8
commit
b4b27a1d0c
6 changed files with 488 additions and 305 deletions
File diff suppressed because one or more lines are too long
|
|
@ -91,15 +91,15 @@ const _ring = LinearGradient(
|
|||
const _ringWidth = 2.0;
|
||||
|
||||
/// A field to ask about [topic], in [_ring], laid out as Claude's apps lay
|
||||
/// theirs (decided 2026-11-02, ADR 0021, D67): what goes along as one row
|
||||
/// above the text (how far an assistant may look, D49 and D50, and the
|
||||
/// parts of the topic chosen, none at first, so only what matters goes:
|
||||
/// decided 2026-10-10), opening a sheet to choose; the files attached; the
|
||||
/// question; and below it + to attach files, who answers as a chip (the
|
||||
/// web search, or an assistant the instance offers, ADR 0021, with how
|
||||
/// much it thinks, D63), the conversations kept (D48), and the microphone
|
||||
/// until something is written, then the button sending. Empty again once
|
||||
/// sent.
|
||||
/// theirs (decided 2026-11-02, ADR 0021, D67; its row D69): the files
|
||||
/// attached; the question; and below it, × and + at the start (+ attaching
|
||||
/// files and leading to what goes along: how far an assistant may look,
|
||||
/// D49 and D50, and the parts of the topic chosen, none at first, so only
|
||||
/// what matters goes, decided 2026-10-10), who answers centred (the web
|
||||
/// search, or an assistant the instance offers, ADR 0021, by its model's
|
||||
/// name in its provider's colour, with how much it thinks, D63), and the
|
||||
/// conversations kept (D48) and the microphone dictating at the end, until
|
||||
/// something could be sent, then the button sending. Empty again once sent.
|
||||
///
|
||||
/// With [onClose], a close button leads its last row, as where the field
|
||||
/// unfolded from a button.
|
||||
|
|
@ -317,11 +317,30 @@ 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(
|
||||
// How far it looks and what goes along, as Claude's sheet has
|
||||
// Permission (the developer's review of 3 Nov 2026), behind + alone
|
||||
// since 4 Nov (D69), the web search's too.
|
||||
final goesAlong = [
|
||||
if (asksAssistant)
|
||||
scopeText(
|
||||
l10n,
|
||||
AssistantScope(reach: _reach, includePrivate: _includePrivate),
|
||||
),
|
||||
if (chosen > 0) l10n.assistantContextChosen(chosen),
|
||||
];
|
||||
final contextRow = (
|
||||
icon: LucideIcons.scanSearch,
|
||||
title: l10n.assistantContext,
|
||||
value: goesAlong.isEmpty ? null : goesAlong.join(' · '),
|
||||
onTap: () => unawaited(_openContext(answerer)),
|
||||
);
|
||||
// [x, +] at the start, who answers in the middle, [history, mic or
|
||||
// send] at the end (D69), each group where it is whatever the name in
|
||||
// the middle (the developer's review of 3 Nov 2026: they shifted as it
|
||||
// changed).
|
||||
final start = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.onClose case final close?) ...[
|
||||
IconButton(
|
||||
|
|
@ -332,57 +351,29 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
),
|
||||
gap,
|
||||
],
|
||||
if (answerer case AssistantAnswerer(:final choice))
|
||||
AttachButton(
|
||||
switch (answerer) {
|
||||
AssistantAnswerer(:final choice) => AttachButton(
|
||||
images: choice.images,
|
||||
assistant: choice.name,
|
||||
onAttached: (part) => setState(() => _attached.add(part)),
|
||||
// How far it looks and what goes along, as Claude's sheet has
|
||||
// Permission (the developer's review of 3 Nov 2026).
|
||||
rows: [
|
||||
(
|
||||
icon: LucideIcons.scanSearch,
|
||||
title: l10n.assistantContext,
|
||||
value: [
|
||||
scopeText(
|
||||
l10n,
|
||||
AssistantScope(
|
||||
reach: _reach,
|
||||
includePrivate: _includePrivate,
|
||||
),
|
||||
),
|
||||
if (chosen > 0) l10n.assistantContextChosen(chosen),
|
||||
].join(' · '),
|
||||
onTap: () => unawaited(_openContext(answerer)),
|
||||
),
|
||||
],
|
||||
rows: [contextRow],
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
// A search takes no files, only the parts chosen.
|
||||
WebAnswerer() when offered.isNotEmpty => AttachButton(
|
||||
files: false,
|
||||
images: false,
|
||||
assistant: l10n.assistantWebSearch,
|
||||
onAttached: (_) {},
|
||||
rows: [contextRow],
|
||||
),
|
||||
),
|
||||
WebAnswerer() => const SizedBox.shrink(),
|
||||
},
|
||||
],
|
||||
);
|
||||
final end = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (answerers.any((each) => each is AssistantAnswerer)) ...[
|
||||
gap,
|
||||
IconButton(
|
||||
tooltip: l10n.assistantHistory,
|
||||
style: boxedButtonStyle(context),
|
||||
|
|
@ -394,8 +385,8 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
),
|
||||
),
|
||||
),
|
||||
gap,
|
||||
],
|
||||
gap,
|
||||
SpeakOrSend(
|
||||
controller: _question,
|
||||
canSend: () =>
|
||||
|
|
@ -410,6 +401,55 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
),
|
||||
],
|
||||
);
|
||||
final controls = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Room for two buttons on either side (each taking a tap target of
|
||||
// 48), so the middle stays centred.
|
||||
const side = 2 * 48.0 + 6;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: start,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: (constraints.maxWidth - 2 * side).clamp(
|
||||
0,
|
||||
double.infinity,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: end,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: const ShapeDecoration(shape: shape, gradient: _ring),
|
||||
child: Padding(
|
||||
|
|
@ -424,20 +464,6 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (offered.isNotEmpty || asksAssistant)
|
||||
_ContextRow(
|
||||
scope: asksAssistant
|
||||
? scopeText(
|
||||
l10n,
|
||||
AssistantScope(
|
||||
reach: _reach,
|
||||
includePrivate: _includePrivate,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
chosen: chosen,
|
||||
onTap: () => unawaited(_openContext(answerer)),
|
||||
),
|
||||
if (attached.isNotEmpty)
|
||||
AttachedFiles(
|
||||
files: attached,
|
||||
|
|
@ -481,63 +507,6 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
}
|
||||
}
|
||||
|
||||
/// What goes along, as one row above the field's text (D67): how far an
|
||||
/// assistant looks ([scope]; none for a web search) and how many parts
|
||||
/// are [chosen], opening the sheet to choose ([onTap]).
|
||||
class const _ContextRow({
|
||||
required final String? scope,
|
||||
required final int chosen,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final quiet = theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return Tooltip(
|
||||
message: l10n.assistantContext,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 6, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.scanSearch,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
scope ?? l10n.assistantContextParts,
|
||||
style: quiet,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (chosen > 0)
|
||||
Text(
|
||||
' · ${l10n.assistantContextChosen(chosen)}',
|
||||
style: quiet?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet choosing what goes along (D67): how far an assistant may
|
||||
/// look among the [reaches] offered here (none for a web search, D49),
|
||||
/// with the switch for private studysets where there are some (D8), which
|
||||
|
|
@ -680,10 +649,12 @@ typedef _Efforts = ({
|
|||
LlmEffort? chosen,
|
||||
});
|
||||
|
||||
/// Who answers, as a chip below the field (D67): its sign and name, and a
|
||||
/// menu of [answerers] under their providers where there is more than
|
||||
/// one, ending with how much it thinks where its [effort] can be chosen
|
||||
/// (D63); just the sign and name where there is nothing to choose.
|
||||
/// Who answers, as a chip below the field (D67): its sign and its model's
|
||||
/// name, outlined in its provider's colour (D60), which names the provider
|
||||
/// so the model's name goes without it (D69: 'Haiku 4.5'); and a menu of
|
||||
/// [answerers] under their providers, by their full names, where there is
|
||||
/// more than one, ending with how much it thinks where its [effort] can be
|
||||
/// chosen (D63); the chip alone where there is nothing to choose.
|
||||
class const _Picker({
|
||||
required final List<Answerer> answerers,
|
||||
required final Answerer current,
|
||||
|
|
@ -696,10 +667,20 @@ class const _Picker({
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final words = HalkyonAppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final name = switch (current) {
|
||||
WebAnswerer() => l10n.assistantWebSearch,
|
||||
AssistantAnswerer(:final choice) => choice.name,
|
||||
final (name, outline) = switch (current) {
|
||||
WebAnswerer() => (
|
||||
l10n.assistantWebSearch,
|
||||
theme.colorScheme.outlineVariant,
|
||||
),
|
||||
AssistantAnswerer(:final choice) => (
|
||||
AssistantProvider.of(choice.host).modelName(choice.name),
|
||||
providerColorIn(context, AssistantProvider.of(choice.host)),
|
||||
),
|
||||
};
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: outline, width: 1.5),
|
||||
);
|
||||
final label = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -717,9 +698,16 @@ class const _Picker({
|
|||
);
|
||||
final efforts = effort;
|
||||
if (answerers.length < 2 && efforts == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: label,
|
||||
return Tooltip(
|
||||
message: _fullName(l10n, current),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
shape: shape,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
MenuItemButton item(Answerer answerer, {Color? color}) => MenuItemButton(
|
||||
|
|
@ -787,7 +775,7 @@ class const _Picker({
|
|||
message: l10n.assistantChoose,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
shape: shape,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
|
|
@ -817,6 +805,13 @@ class const _Picker({
|
|||
}
|
||||
}
|
||||
|
||||
/// [answerer]'s full name, as the menu shows it.
|
||||
String _fullName(AppLocalizations l10n, Answerer answerer) =>
|
||||
switch (answerer) {
|
||||
WebAnswerer() => l10n.assistantWebSearch,
|
||||
AssistantAnswerer(:final choice) => choice.name,
|
||||
};
|
||||
|
||||
/// An answerer's name in the picker's menu, with what to know beneath:
|
||||
/// that it must be agreed to first, or that it asks with the person's key.
|
||||
class const _AnswererLabel({required final Answerer answerer})
|
||||
|
|
|
|||
|
|
@ -11,21 +11,32 @@ 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. Boxed as Claude's are, each on
|
||||
// a tile of its own (the developer's review of 3 Nov 2026).
|
||||
// D67): + attaching files, and the microphone dictating while nothing could
|
||||
// be sent, which gives way to the button that sends (D69). Boxed as
|
||||
// Claude's are, each on a tile of its own (the developer's review of 3 Nov
|
||||
// 2026), the microphone listening and Send too (4 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,
|
||||
);
|
||||
/// shade above the field's ([background] where it stands out, as Send),
|
||||
/// its corners rounded.
|
||||
ButtonStyle boxedButtonStyle(
|
||||
BuildContext context, {
|
||||
Color? background,
|
||||
Color? foreground,
|
||||
}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return IconButton.styleFrom(
|
||||
backgroundColor: background ?? colors.surfaceContainerHighest,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: colors.surfaceContainerHighest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
fixedSize: const Size.square(40),
|
||||
minimumSize: const Size.square(40),
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
}
|
||||
|
||||
/// What + offers besides the files: a row leading to another choice, as
|
||||
/// Claude's sheet has Permission (the developer's review of 3 Nov 2026).
|
||||
|
|
@ -39,8 +50,8 @@ typedef AddRow = ({
|
|||
/// The + below a question's field: a sheet as Claude's "Add to session"
|
||||
/// (the developer's review of 3 Nov 2026), three tiles above, Camera and
|
||||
/// Photos where the assistant [assistant] reads [images] (Camera where the
|
||||
/// device takes photos), and Files; then [rows], each leading to a choice
|
||||
/// of its own. What is taken or picked is read on the device
|
||||
/// device takes photos), and Files, none without [files] (a web search's);
|
||||
/// then [rows], each leading to a choice of its own. What is taken or picked is read on the device
|
||||
/// ([AssistantAttachments]), each read handing its part to [onAttached],
|
||||
/// each refused saying why.
|
||||
class const AttachButton({
|
||||
|
|
@ -48,6 +59,9 @@ class const AttachButton({
|
|||
required final String assistant,
|
||||
required final ValueChanged<AssistantPart> onAttached,
|
||||
final List<AddRow> rows = const [],
|
||||
|
||||
/// Whether files may be attached at all; a web search takes none.
|
||||
final bool files = true,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// Reads the [picked] files, as images only where the assistant reads
|
||||
|
|
@ -89,9 +103,9 @@ class const AttachButton({
|
|||
context,
|
||||
builder: (sheet) => _AddSheet(
|
||||
sources: [
|
||||
if (images && photos.hasCamera) _Source.camera,
|
||||
if (images) _Source.photos,
|
||||
_Source.files,
|
||||
if (files && images && photos.hasCamera) _Source.camera,
|
||||
if (files && images) _Source.photos,
|
||||
if (files) _Source.files,
|
||||
],
|
||||
rows: rows,
|
||||
),
|
||||
|
|
@ -116,7 +130,11 @@ class const AttachButton({
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return IconButton(
|
||||
tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile,
|
||||
tooltip: !files
|
||||
? l10n.assistantContext
|
||||
: images
|
||||
? l10n.assistantAttach
|
||||
: l10n.assistantAttachFile,
|
||||
style: boxedButtonStyle(context),
|
||||
icon: const Icon(LucideIcons.plus, size: 20),
|
||||
onPressed: () => unawaited(_open(context)),
|
||||
|
|
@ -147,30 +165,31 @@ class const _AddSheet({
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (final (index, source) in sources.indexed) ...[
|
||||
if (index > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _Tile(
|
||||
icon: switch (source) {
|
||||
_Source.camera => LucideIcons.camera,
|
||||
_Source.photos => LucideIcons.image,
|
||||
_Source.files => LucideIcons.fileUp,
|
||||
},
|
||||
label: switch (source) {
|
||||
_Source.camera => l10n.assistantAddCamera,
|
||||
_Source.photos => l10n.assistantAddPhotos,
|
||||
_Source.files => l10n.assistantAddFiles,
|
||||
},
|
||||
onTap: () => Navigator.of(context).pop(source),
|
||||
if (sources.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
for (final (index, source) in sources.indexed) ...[
|
||||
if (index > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _Tile(
|
||||
icon: switch (source) {
|
||||
_Source.camera => LucideIcons.camera,
|
||||
_Source.photos => LucideIcons.image,
|
||||
_Source.files => LucideIcons.fileUp,
|
||||
},
|
||||
label: switch (source) {
|
||||
_Source.camera => l10n.assistantAddCamera,
|
||||
_Source.photos => l10n.assistantAddPhotos,
|
||||
_Source.files => l10n.assistantAddFiles,
|
||||
},
|
||||
onTap: () => Navigator.of(context).pop(source),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (rows.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (sources.isNotEmpty) const SizedBox(height: 12),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
|
|
@ -343,12 +362,13 @@ class const AttachedFiles({
|
|||
}
|
||||
}
|
||||
|
||||
/// The button ending a question's row: while nothing is written, the
|
||||
/// microphone, where the device recognises speech (`SpeechInput`), what
|
||||
/// is said written into [controller] in the app's language; once
|
||||
/// something is, or where nothing listens, the button sending, [onSend],
|
||||
/// enabled while [canSend]. Sending also shows while nothing is written
|
||||
/// but [canSend] holds, as for parts chosen alone.
|
||||
/// The button ending a question's row (D69): while nothing could be sent,
|
||||
/// the microphone, where the device recognises speech (`SpeechInput`),
|
||||
/// dictating into [controller] in the app's language where its cursor
|
||||
/// stands and listening again after each pause (halkyon_app's
|
||||
/// [Dictation]), until stopped; once something could be sent (a word
|
||||
/// written, a part chosen, a file attached: [canSend]), or where nothing
|
||||
/// listens, the button sending, [onSend]. Each boxed as + is.
|
||||
class const SpeakOrSend({
|
||||
required final TextEditingController controller,
|
||||
required final bool Function() canSend,
|
||||
|
|
@ -363,94 +383,71 @@ class const SpeakOrSend({
|
|||
}
|
||||
|
||||
class _SpeakOrSendState() extends State<SpeakOrSend> {
|
||||
late final _speech = Dependencies.of(context).speech;
|
||||
var _listening = false;
|
||||
|
||||
/// What the field held as listening began, what is heard going after it.
|
||||
var _before = '';
|
||||
late final _dictation = Dictation(
|
||||
Dependencies.of(context).speech,
|
||||
widget.controller,
|
||||
);
|
||||
|
||||
Future<void> _listen() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final words = HalkyonAppLocalizations.of(context);
|
||||
final before = widget.controller.text.trimRight();
|
||||
_before = before.isEmpty ? '' : '$before ';
|
||||
setState(() => _listening = true);
|
||||
final listens = await _speech.listen(
|
||||
final problem = switch (await _dictation.start(
|
||||
language: Localizations.localeOf(context).toLanguageTag(),
|
||||
onWords: (words) {
|
||||
if (mounted && _listening) {
|
||||
final text = '$_before$words';
|
||||
widget.controller.value = TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted) {
|
||||
setState(() => _listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!listens.listens && mounted) {
|
||||
setState(() => _listening = false);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(switch (listens) {
|
||||
SpeechStart.noRecognizer => words.speechNoRecognizer,
|
||||
_ => l10n.assistantSpeechUnavailable,
|
||||
}),
|
||||
),
|
||||
);
|
||||
)) {
|
||||
SpeechStart.listening => null,
|
||||
// A phone without Google's services has no recogniser installed.
|
||||
SpeechStart.noRecognizer => words.speechNoRecognizer,
|
||||
SpeechStart.unavailable => l10n.assistantSpeechUnavailable,
|
||||
};
|
||||
if (problem != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problem)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_listening) {
|
||||
unawaited(_speech.stop());
|
||||
}
|
||||
_dictation.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (_listening) {
|
||||
return IconButton.filledTonal(
|
||||
tooltip: l10n.assistantStopListening,
|
||||
icon: const Icon(LucideIcons.square),
|
||||
onPressed: () => unawaited(_speech.stop()),
|
||||
);
|
||||
}
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
listenable: Listenable.merge([widget.controller, _dictation]),
|
||||
builder: (context, _) {
|
||||
final written = widget.controller.text.trim().isNotEmpty;
|
||||
final canSend = widget.canSend();
|
||||
final send = IconButton.filled(
|
||||
tooltip: widget.sendTooltip,
|
||||
icon: Icon(widget.sendIcon),
|
||||
onPressed: canSend ? widget.onSend : null,
|
||||
);
|
||||
if (written || !_speech.supported) {
|
||||
return send;
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.assistantSpeak,
|
||||
style: boxedButtonStyle(context),
|
||||
icon: const Icon(LucideIcons.mic, size: 20),
|
||||
onPressed: () => unawaited(_listen()),
|
||||
if (_dictation.listening) {
|
||||
return IconButton(
|
||||
tooltip: l10n.assistantStopListening,
|
||||
style: boxedButtonStyle(
|
||||
context,
|
||||
background: colors.primaryContainer,
|
||||
foreground: colors.onPrimaryContainer,
|
||||
),
|
||||
if (canSend) ...[
|
||||
const SizedBox(width: 6),
|
||||
send,
|
||||
],
|
||||
],
|
||||
icon: const Icon(LucideIcons.square, size: 18),
|
||||
onPressed: () => unawaited(_dictation.stop()),
|
||||
);
|
||||
}
|
||||
final canSend = widget.canSend();
|
||||
if (canSend || !_dictation.supported) {
|
||||
return IconButton(
|
||||
tooltip: widget.sendTooltip,
|
||||
style: boxedButtonStyle(
|
||||
context,
|
||||
background: colors.primary,
|
||||
foreground: colors.onPrimary,
|
||||
),
|
||||
icon: Icon(widget.sendIcon, size: 20),
|
||||
onPressed: canSend ? widget.onSend : null,
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
tooltip: l10n.assistantSpeak,
|
||||
style: boxedButtonStyle(context),
|
||||
icon: const Icon(LucideIcons.mic, size: 20),
|
||||
onPressed: () => unawaited(_listen()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -861,9 +861,18 @@ class _FollowUpState() extends State<_FollowUp> {
|
|||
),
|
||||
const Spacer(),
|
||||
if (widget.asking)
|
||||
IconButton.filledTonal(
|
||||
IconButton(
|
||||
tooltip: l10n.assistantStop,
|
||||
icon: const Icon(LucideIcons.square),
|
||||
style: boxedButtonStyle(
|
||||
context,
|
||||
background: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
foreground: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
icon: const Icon(LucideIcons.square, size: 18),
|
||||
onPressed: () =>
|
||||
unawaited(context.read<ConversationCubit>().stop()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -380,6 +380,32 @@ void main() {
|
|||
expect(onDevice, ['what does 火 mean']);
|
||||
});
|
||||
|
||||
testWidgets('the microphone listens again after a pause until stopped, '
|
||||
'each listen going where the cursor stands (D69)', (tester) async {
|
||||
await showApp(tester);
|
||||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('Speak your question'));
|
||||
await tester.pump();
|
||||
speech.say('what does 火 mean');
|
||||
await tester.pump();
|
||||
// The cursor placed at the start before the pause, the next listen's
|
||||
// words go there.
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.selection =
|
||||
const TextSelection.collapsed(offset: 0);
|
||||
speech.finish();
|
||||
await tester.pump();
|
||||
expect(find.byTooltip('Stop listening'), findsOneWidget, reason: 'on');
|
||||
expect(speech.listening, isTrue, reason: 'listening again');
|
||||
speech.say('Tell me');
|
||||
await tester.pump();
|
||||
expect(find.text('Tell me what does 火 mean'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Stop listening'));
|
||||
await tester.pump();
|
||||
expect(speech.listening, isFalse);
|
||||
expect(find.byTooltip('Search'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says so where no speech recognition is installed, and how '
|
||||
'to get one, never stuck listening (GrapheneOS, 4 Nov 2026)', (
|
||||
tester,
|
||||
|
|
@ -617,10 +643,13 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await unfold(tester);
|
||||
// What goes along is one row above the field, opening its sheet (D67).
|
||||
expect(find.text('Send along'), findsOneWidget);
|
||||
// What goes along is +'s one row for a search, opening its sheet (D69).
|
||||
expect(find.text('Send along'), findsNothing);
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Files'), findsNothing, reason: 'a search takes none');
|
||||
await tester.tap(find.text('What goes along').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Studyset: Japanese'), findsNothing);
|
||||
expect(find.text('How far it looks'), findsNothing);
|
||||
await tester.tap(find.text('Front: ひ'));
|
||||
|
|
@ -628,7 +657,11 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(' · 2 parts'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('2 parts'), findsOneWidget, reason: "+'s row says so");
|
||||
await tester.tapAt(const Offset(200, 20));
|
||||
await tester.pumpAndSettle();
|
||||
await send(tester, 'mnemonic');
|
||||
expect(searched.last.queryParameters['q'], 'mnemonic ひ hi');
|
||||
searched.clear();
|
||||
|
|
@ -636,6 +669,8 @@ void main() {
|
|||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('What goes along').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.textContaining('Hint 1: ひらがな'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Done'));
|
||||
|
|
|
|||
|
|
@ -255,9 +255,16 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the field's sheet of what goes along (D67).
|
||||
/// Opens the field's sheet of what goes along, through + (D67, D69).
|
||||
Future<void> openContext(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.widgetWithIcon(IconButton, LucideIcons.plus),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('What goes along').last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
|
@ -339,15 +346,17 @@ 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 {
|
||||
testWidgets('the row below the field: close and + at the start, who '
|
||||
"answers centred by its model alone, outlined in its provider's "
|
||||
'colour, the conversations and the microphone at the end, each where '
|
||||
'it is whatever the name; every button boxed, listening and Send too '
|
||||
'(D69)', (tester) async {
|
||||
assistants.offer = AssistantsOffer(
|
||||
assistants: [
|
||||
_claude(),
|
||||
OfferedAssistant(
|
||||
id: 'opus',
|
||||
name: 'Claude Opus, thinking longer',
|
||||
name: 'Claude Opus 4.1',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus',
|
||||
|
|
@ -356,41 +365,79 @@ void main() {
|
|||
],
|
||||
);
|
||||
await showApp(tester);
|
||||
Rect at(String tooltip) => tester.getRect(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip(tooltip),
|
||||
),
|
||||
Finder inField(Finder finder) =>
|
||||
find.descendant(of: find.byType(AssistantField), matching: finder);
|
||||
Rect at(String tooltip) => tester.getRect(inField(find.byTooltip(tooltip)));
|
||||
final field = tester.getRect(find.byType(AssistantField));
|
||||
final close = at('Close');
|
||||
final plus = tester.getRect(
|
||||
inField(find.widgetWithIcon(IconButton, LucideIcons.plus)),
|
||||
);
|
||||
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');
|
||||
expect(close.left - field.left, lessThan(16), reason: 'at the start');
|
||||
expect(close.right, lessThan(plus.left), reason: '+ after it');
|
||||
expect(field.right - speak.right, lessThan(16), reason: 'at the end');
|
||||
expect(history.right, lessThan(speak.left), reason: 'the history before');
|
||||
|
||||
await openPicker(tester);
|
||||
await tester.tap(find.text('Claude Opus, thinking longer').last);
|
||||
await tester.tap(find.text('Claude Opus 4.1').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Ask Claude Opus, thinking longer…'), findsOneWidget);
|
||||
expect(find.text('Ask Claude Opus 4.1…'), findsOneWidget);
|
||||
final picker = inField(find.byTooltip('Who answers'));
|
||||
expect(
|
||||
find.descendant(of: picker, matching: find.text('Opus 4.1')),
|
||||
findsOneWidget,
|
||||
reason: 'the model alone, its provider by the colour',
|
||||
);
|
||||
expect(tester.getRect(picker).center.dx, closeTo(field.center.dx, 2));
|
||||
final outline = tester
|
||||
.widgetList<Material>(
|
||||
find.descendant(of: picker, matching: find.byType(Material)),
|
||||
)
|
||||
.map((material) => material.shape)
|
||||
.whereType<RoundedRectangleBorder>()
|
||||
.first
|
||||
.side
|
||||
.color;
|
||||
expect(
|
||||
outline,
|
||||
providerColorIn(
|
||||
tester.element(picker),
|
||||
AssistantProvider.anthropic,
|
||||
),
|
||||
);
|
||||
expect(at('Close'), close);
|
||||
expect(at('Conversations'), history);
|
||||
expect(at('Speak your question'), speak);
|
||||
|
||||
void expectBoxed(Finder button, String what) => expect(
|
||||
tester.widget<IconButton>(button).style?.backgroundColor?.resolve({}),
|
||||
isNotNull,
|
||||
reason: '$what boxed',
|
||||
);
|
||||
for (final icon in [
|
||||
LucideIcons.x,
|
||||
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',
|
||||
);
|
||||
expectBoxed(inField(find.widgetWithIcon(IconButton, icon)), '$icon');
|
||||
}
|
||||
await tester.tap(find.byTooltip('Speak your question'));
|
||||
await tester.pump();
|
||||
expectBoxed(
|
||||
inField(find.widgetWithIcon(IconButton, LucideIcons.square)),
|
||||
'listening',
|
||||
);
|
||||
speech.say('Why');
|
||||
await tester.tap(find.byTooltip('Stop listening'));
|
||||
await tester.pump();
|
||||
expectBoxed(
|
||||
inField(find.widgetWithIcon(IconButton, LucideIcons.arrowUp)),
|
||||
'Send',
|
||||
);
|
||||
expect(at('Ask'), speak, reason: 'Send in the place of the microphone');
|
||||
});
|
||||
|
||||
testWidgets("an answer's Copy stands beneath it, its icon in line with "
|
||||
|
|
@ -665,7 +712,11 @@ void main() {
|
|||
) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await unfold(tester);
|
||||
expect(find.text('Only what I choose'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Only what I choose'),
|
||||
findsNothing,
|
||||
reason: 'behind + alone (D69)',
|
||||
);
|
||||
|
||||
await openContext(tester);
|
||||
expect(find.text('Studyset: Japanese'), findsOneWidget);
|
||||
|
|
@ -678,7 +729,11 @@ void main() {
|
|||
await tester.tap(find.text('This studyset with its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
await closeContext(tester);
|
||||
expect(find.text('This studyset'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithIcon(IconButton, LucideIcons.plus));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('This studyset'), findsOneWidget, reason: "+'s row");
|
||||
await tester.tapAt(const Offset(200, 20));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
assistants.answers.add(_says('Verbs, mostly.'));
|
||||
await ask(tester, 'What is in here?');
|
||||
|
|
@ -880,17 +935,17 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
Finder inField(String text) => find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.text(text),
|
||||
);
|
||||
await openContext(tester);
|
||||
await tester.tap(find.text('My whole library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Include private studysets'));
|
||||
await tester.pumpAndSettle();
|
||||
await closeContext(tester);
|
||||
expect(inField('Library, private ones too'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithIcon(IconButton, LucideIcons.plus));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Library, private ones too'), findsOneWidget);
|
||||
await tester.tapAt(const Offset(200, 20));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await ask(tester, 'What do I study?');
|
||||
|
||||
|
|
@ -1358,6 +1413,22 @@ void main() {
|
|||
..add(_says('Lines one and two.'));
|
||||
await ask(tester, 'Why て-form?');
|
||||
|
||||
Finder inSheet(String tooltip) => find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip(tooltip),
|
||||
);
|
||||
await tester.tap(inSheet('Speak your question'));
|
||||
await tester.pump();
|
||||
speech.say('And these?');
|
||||
await tester.pump();
|
||||
await tester.tap(inSheet('Stop listening'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'And these?'),
|
||||
findsOneWidget,
|
||||
reason: 'dictated into it',
|
||||
);
|
||||
|
||||
picker.next = (name: 'lines.txt', bytes: bytesOf('One.\nTwo.'));
|
||||
await add(
|
||||
tester,
|
||||
|
|
@ -1366,18 +1437,6 @@ void main() {
|
|||
within: find.byType(BottomSheet),
|
||||
);
|
||||
expect(find.widgetWithText(InputChip, 'lines.txt'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip('Speak your question'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Ask more…'),
|
||||
'And these?',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
|
|
|
|||
Loading…
Reference in a new issue