Merge branch 'learn-fb' into integration

This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:13:32 +01:00
commit 918080ce46
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
56 changed files with 2471 additions and 368 deletions

View file

@ -96,5 +96,10 @@
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<!-- The device's text-to-speech engines, which read an assistant's
answers aloud in voice mode (ADR 0021, D68). -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
</queries>
</manifest>

View file

@ -2129,5 +2129,21 @@
"studysetPrivacyReadableTitle": "„{title}“ ist nicht privat",
"studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.",
"studysetPrivacyPublish": "Veröffentlichen …"
"studysetPrivacyPublish": "Veröffentlichen …",
"studysetSheetVisibility": "Sichtbarkeit",
"studysetSheetPrivate": "Privat",
"assistantAddTitle": "Zu deiner Frage hinzufügen",
"assistantAddCamera": "Kamera",
"assistantAddPhotos": "Fotos",
"assistantAddFiles": "Dateien",
"assistantTalk": "Sprachmodus",
"voiceTitle": "Lass uns reden",
"voiceListening": "Ich höre zu …",
"voiceThinking": "Ich denke nach …",
"voiceTapToTalk": "Tippe aufs Mikrofon, um zu sprechen",
"voiceTalk": "Sprechen",
"voiceDone": "Senden",
"voiceSpeakerOff": "Antworten nicht vorlesen",
"voiceSpeakerOn": "Antworten vorlesen",
"voiceEnd": "Sprachmodus beenden"
}

View file

@ -2545,5 +2545,37 @@
"@studysetPrivacyReadableTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetPrivacyReadableBody": "The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.",
"studysetPrivacyTopic": "Its privacy is that of the studyset it's in.",
"studysetPrivacyPublish": "Publish…"
"studysetPrivacyPublish": "Publish…",
"studysetSheetVisibility": "Visibility",
"@studysetSheetVisibility": {"description": "Heading in a top-level studyset's Edit sheet above its privacy switch (ADR 0019)."},
"studysetSheetPrivate": "Private",
"@studysetSheetPrivate": {"description": "The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet."},
"assistantAddTitle": "Add to your question",
"@assistantAddTitle": {"description": "Title of the sheet + opens below the assistant's field, as Claude's \"Add to session\"."},
"assistantAddCamera": "Camera",
"@assistantAddCamera": {"description": "Tile in +'s sheet: takes a photo with the camera's own app."},
"assistantAddPhotos": "Photos",
"@assistantAddPhotos": {"description": "Tile in +'s sheet: picks photos from the device."},
"assistantAddFiles": "Files",
"@assistantAddFiles": {"description": "Tile in +'s sheet: picks files (PDFs, texts, images)."},
"assistantTalk": "Voice mode",
"@assistantTalk": {"description": "The field's button opening voice mode, a conversation spoken rather than typed (ADR 0021, D68)"},
"voiceTitle": "Let's talk",
"@voiceTitle": {"description": "Voice mode's heading"},
"voiceListening": "Listening…",
"@voiceListening": {"description": "Voice mode while it hears the person"},
"voiceThinking": "Thinking…",
"@voiceThinking": {"description": "Voice mode while the answer is awaited"},
"voiceTapToTalk": "Tap the microphone to talk",
"@voiceTapToTalk": {"description": "Voice mode waiting for the microphone"},
"voiceTalk": "Talk",
"@voiceTalk": {"description": "The microphone's tooltip: listens, stopping any reading"},
"voiceDone": "Send",
"@voiceDone": {"description": "The microphone's tooltip while listening: stops and asks what was heard"},
"voiceSpeakerOff": "Don't read answers aloud",
"@voiceSpeakerOff": {"description": "Switches reading answers aloud off"},
"voiceSpeakerOn": "Read answers aloud",
"@voiceSpeakerOn": {"description": "Switches reading answers aloud on"},
"voiceEnd": "End voice mode",
"@voiceEnd": {"description": "Closes voice mode"}
}

View file

@ -12565,6 +12565,102 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Publish…'**
String get studysetPrivacyPublish;
/// Heading in a top-level studyset's Edit sheet above its privacy switch (ADR 0019).
///
/// In en, this message translates to:
/// **'Visibility'**
String get studysetSheetVisibility;
/// The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet.
///
/// In en, this message translates to:
/// **'Private'**
String get studysetSheetPrivate;
/// Title of the sheet + opens below the assistant's field, as Claude's "Add to session".
///
/// In en, this message translates to:
/// **'Add to your question'**
String get assistantAddTitle;
/// Tile in +'s sheet: takes a photo with the camera's own app.
///
/// In en, this message translates to:
/// **'Camera'**
String get assistantAddCamera;
/// Tile in +'s sheet: picks photos from the device.
///
/// In en, this message translates to:
/// **'Photos'**
String get assistantAddPhotos;
/// Tile in +'s sheet: picks files (PDFs, texts, images).
///
/// In en, this message translates to:
/// **'Files'**
String get assistantAddFiles;
/// The field's button opening voice mode, a conversation spoken rather than typed (ADR 0021, D68)
///
/// In en, this message translates to:
/// **'Voice mode'**
String get assistantTalk;
/// Voice mode's heading
///
/// In en, this message translates to:
/// **'Let\'s talk'**
String get voiceTitle;
/// Voice mode while it hears the person
///
/// In en, this message translates to:
/// **'Listening…'**
String get voiceListening;
/// Voice mode while the answer is awaited
///
/// In en, this message translates to:
/// **'Thinking…'**
String get voiceThinking;
/// Voice mode waiting for the microphone
///
/// In en, this message translates to:
/// **'Tap the microphone to talk'**
String get voiceTapToTalk;
/// The microphone's tooltip: listens, stopping any reading
///
/// In en, this message translates to:
/// **'Talk'**
String get voiceTalk;
/// The microphone's tooltip while listening: stops and asks what was heard
///
/// In en, this message translates to:
/// **'Send'**
String get voiceDone;
/// Switches reading answers aloud off
///
/// In en, this message translates to:
/// **'Don\'t read answers aloud'**
String get voiceSpeakerOff;
/// Switches reading answers aloud on
///
/// In en, this message translates to:
/// **'Read answers aloud'**
String get voiceSpeakerOn;
/// Closes voice mode
///
/// In en, this message translates to:
/// **'End voice mode'**
String get voiceEnd;
}
class _AppLocalizationsDelegate

View file

@ -8061,4 +8061,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetPrivacyPublish => 'Veröffentlichen …';
@override
String get studysetSheetVisibility => 'Sichtbarkeit';
@override
String get studysetSheetPrivate => 'Privat';
@override
String get assistantAddTitle => 'Zu deiner Frage hinzufügen';
@override
String get assistantAddCamera => 'Kamera';
@override
String get assistantAddPhotos => 'Fotos';
@override
String get assistantAddFiles => 'Dateien';
@override
String get assistantTalk => 'Sprachmodus';
@override
String get voiceTitle => 'Lass uns reden';
@override
String get voiceListening => 'Ich höre zu …';
@override
String get voiceThinking => 'Ich denke nach …';
@override
String get voiceTapToTalk => 'Tippe aufs Mikrofon, um zu sprechen';
@override
String get voiceTalk => 'Sprechen';
@override
String get voiceDone => 'Senden';
@override
String get voiceSpeakerOff => 'Antworten nicht vorlesen';
@override
String get voiceSpeakerOn => 'Antworten vorlesen';
@override
String get voiceEnd => 'Sprachmodus beenden';
}

View file

@ -7988,6 +7988,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetPrivacyPublish => 'Publish…';
@override
String get studysetSheetVisibility => 'Visibility';
@override
String get studysetSheetPrivate => 'Private';
@override
String get assistantAddTitle => 'Add to your question';
@override
String get assistantAddCamera => 'Camera';
@override
String get assistantAddPhotos => 'Photos';
@override
String get assistantAddFiles => 'Files';
@override
String get assistantTalk => 'Voice mode';
@override
String get voiceTitle => 'Let\'s talk';
@override
String get voiceListening => 'Listening…';
@override
String get voiceThinking => 'Thinking…';
@override
String get voiceTapToTalk => 'Tap the microphone to talk';
@override
String get voiceTalk => 'Talk';
@override
String get voiceDone => 'Send';
@override
String get voiceSpeakerOff => 'Don\'t read answers aloud';
@override
String get voiceSpeakerOn => 'Read answers aloud';
@override
String get voiceEnd => 'End voice mode';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -27,6 +27,8 @@ import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.da
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_output.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
@ -197,6 +199,12 @@ class Dependencies({
/// Turns a question spoken into its words, on the device (D67).
required final SpeechInput speech,
/// Photos taken or picked to go with a question (D67).
required final PhotoPicker photos,
/// Reads an assistant's answers aloud in voice mode, on the device (D68).
required final SpeechOutput speechOutput,
/// The person's own Jellyfin server, to watch and learn from: the
/// device's, kept across instances.
required final JellyfinRepository jellyfin,
@ -331,6 +339,8 @@ class Dependencies({
pomodoro: device.pomodoro,
webSearch: WebSearch(),
speech: DeviceSpeechInput(),
photos: ImagePickerPhotoPicker(),
speechOutput: DeviceSpeechOutput(),
jellyfin: device.jellyfin,
jellyfinSuggestion: JellyfinSuggestion(
() async => (await client.instance.info()).jellyfinUrl,

View file

@ -0,0 +1,53 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:image_picker/image_picker.dart' show ImagePicker, ImageSource, XFile;
/// Photos to go with a question (ADR 0021, D67; the developer's review of
/// 3 Nov 2026): one taken with the device's camera, or some picked from
/// its photos, read on the device and sent only with the question.
abstract interface class PhotoPicker() {
/// Whether this device takes photos: phones do, desktops don't.
bool get hasCamera;
/// A photo the person takes with the camera's own app; null if they
/// took none.
Future<PickedFile?> take();
/// The photos the person picks; none if they picked none.
Future<List<PickedFile>> pick();
}
/// [PhotoPicker] through `image_picker`: the camera's own app, and the
/// system's photo picker on Android (no permission asked) and iOS, a file
/// dialog on the desktop. Photos are made at most [maxSide] wide and high,
/// which providers read as well, and small enough to send.
class ImagePickerPhotoPicker({final double maxSide = 2048})
implements PhotoPicker {
final _picker = ImagePicker();
@override
bool get hasCamera => _picker.supportsImageSource(ImageSource.camera);
@override
Future<PickedFile?> take() async {
final photo = await _picker.pickImage(
source: ImageSource.camera,
maxWidth: maxSide,
maxHeight: maxSide,
imageQuality: 85,
);
return photo == null ? null : await _read(photo);
}
@override
Future<List<PickedFile>> pick() async => [
for (final photo in await _picker.pickMultiImage(
maxWidth: maxSide,
maxHeight: maxSide,
imageQuality: 85,
))
await _read(photo),
];
static Future<PickedFile> _read(XFile photo) async =>
(name: photo.name, bytes: await photo.readAsBytes());
}

View file

@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart';
/// Reads text aloud with the device's own text-to-speech (ADR 0021, D68):
/// Android's engine, Apple's, Windows'. Nothing leaves the device.
abstract interface class SpeechOutput() {
/// Whether this kind of device reads aloud at all; Linux doesn't.
bool get supported;
/// Reads [text] aloud in [language] (a BCP 47 tag; the device's own
/// voice where it has none for it), completing once it has been read,
/// stopped, or couldn't be.
Future<void> speak(String text, {String? language});
/// Stops reading.
Future<void> stop();
}
/// [SpeechOutput] through `flutter_tts`.
class DeviceSpeechOutput() implements SpeechOutput {
final _tts = FlutterTts();
/// Completes the reading under way.
Completer<void>? _reading;
var _ready = false;
@override
bool get supported => switch (defaultTargetPlatform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.macOS ||
TargetPlatform.windows => true,
TargetPlatform.linux || TargetPlatform.fuchsia => false,
};
void _done() {
final reading = _reading;
_reading = null;
if (reading != null && !reading.isCompleted) {
reading.complete();
}
}
@override
Future<void> speak(String text, {String? language}) async {
if (!supported || text.trim().isEmpty) {
return;
}
try {
if (!_ready) {
_ready = true;
_tts
..setCompletionHandler(_done)
..setCancelHandler(_done)
..setErrorHandler((_) => _done());
}
if (language != null &&
await _tts.isLanguageAvailable(language) == true) {
await _tts.setLanguage(language);
}
_done();
final reading = _reading = Completer<void>();
await _tts.speak(text);
await reading.future;
} on Exception {
// No engine, or none that speaks: the answer stays to be read.
_done();
}
}
@override
Future<void> stop() async {
try {
await _tts.stop();
} on Exception {
// Nothing was being read.
}
_done();
}
}
/// [markdown], an assistant's answer, as it is read aloud: without its
/// marks (emphasis, headings, code, a link's address, a list's bullets, a
/// table's bars), which a voice would otherwise spell out.
String speakableText(String markdown) {
final lines = <String>[];
for (final line in markdown.split('\n')) {
final trimmed = line.trim();
// Code fences and a table's rule go; a table's row reads as its cells.
if (trimmed.startsWith('```') ||
RegExp(r'^\|?[ \t:|-]+\|[ \t:|-]*$').hasMatch(trimmed)) {
continue;
}
if (trimmed.startsWith('|')) {
lines.add(
[
for (final cell
in trimmed.replaceAll(RegExp(r'^\||\|$'), '').split('|'))
cell.trim(),
].join(', '),
);
continue;
}
lines.add(
line
// Headings, quotes and bullets at a line's start.
.replaceFirst(RegExp(r'^[ \t]{0,3}(#{1,6}|>|[-*+]|\d+\.)[ \t]+'), ''),
);
}
final text = lines
.join('\n')
// [a link](address) reads as its words.
.replaceAllMapped(
RegExp(r'!?\[([^\]]*)\]\([^)]*\)'),
(match) => match.group(1)!,
)
// Emphasis, inline code and formulas' dollars.
.replaceAll(RegExp(r'(\*\*|__|\*|_|`|~~|\$)'), '')
.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}

View file

@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_wor
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/composer.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_history.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/voice_mode.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
@ -273,6 +274,43 @@ class _AssistantFieldState() extends State<AssistantField> {
widget.onAsked?.call();
}
/// Opens voice mode with [choice] (ADR 0021, D68), within the field's
/// scope, the parts chosen and attached going with the first question;
/// first agreed to as any question to [choice] (D40, D9, D17).
Future<void> _talk(AssistantChoice choice, List<Answerer> answerers) async {
final answerer = AssistantAnswerer(choice);
final chosen = [
for (final part in _offered(answerer))
if (_chosen.contains(part)) part,
];
final attached = _attachedFor(answerer);
final scope = AssistantScope(
reach: _reach,
studysetId: widget.topic.studysetId,
includePrivate: _reach != AssistantReach.ticked && _includePrivate,
partsFrom: widget.topic.partsFrom(chosen),
);
if (!await readyToAsk(context, choice: choice, scope: scope) || !mounted) {
return;
}
final effort = effortFor(
choice,
context.read<DeviceSettingsBloc>().state.askEfforts,
);
_clear();
await showVoiceMode(
context,
choice: choice,
assistants: [
for (final each in answerers)
if (each case AssistantAnswerer(:final choice)) choice,
],
scope: scope,
chosen: [...chosen, ...attached],
effort: effort,
);
}
/// Opens the sheet choosing what goes along with [answerer]'s question.
Future<void> _openContext(Answerer answerer) => showAdaptiveSheet<void>(
context,
@ -317,48 +355,76 @@ 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)),
// 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)),
),
],
),
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 +432,8 @@ class _AssistantFieldState() extends State<AssistantField> {
),
),
),
],
gap,
SpeakOrSend(
controller: _question,
canSend: () =>
@ -377,6 +445,12 @@ class _AssistantFieldState() extends State<AssistantField> {
? l10n.assistantSendAsk
: l10n.assistantSendWeb,
sendIcon: asksAssistant ? LucideIcons.arrowUp : LucideIcons.search,
onTalk: switch (answerer) {
AssistantAnswerer(:final choice) => () => unawaited(
_talk(choice, answerers),
),
WebAnswerer() => null,
},
),
],
);
@ -389,7 +463,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,
@ -534,6 +608,11 @@ class _ContextSheetState() extends State<_ContextSheet> {
late var _includePrivate = widget.includePrivate;
late final _chosen = {...widget.chosen};
void _setPrivate(bool on) {
setState(() => _includePrivate = on);
widget.onIncludePrivate(on);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -544,88 +623,82 @@ class _ContextSheetState() extends State<_ContextSheet> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rows in rounded groups, as Claude's Permission, rather than
// radio buttons and chips (the developer's review of 3 Nov 2026).
if (widget.reaches.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 8),
child: Text(l10n.assistantContextReach, style: heading),
),
RadioGroup<AssistantReach>(
groupValue: _reach,
onChanged: (reach) {
if (reach != null) {
setState(() => _reach = reach);
widget.onReach(reach);
}
},
child: Column(
children: [
for (final each in widget.reaches)
RadioListTile<AssistantReach>(
value: each,
title: Text(reachLongText(l10n, each)),
RoundedGroup(
children: [
for (final each in widget.reaches)
RoundedRow(
icon: switch (each) {
AssistantReach.ticked => LucideIcons.listChecks,
AssistantReach.studyset => LucideIcons.bookOpen,
AssistantReach.tree => LucideIcons.folderTree,
AssistantReach.library => LucideIcons.libraryBig,
},
title: reachLongText(l10n, each),
trailing: _Check(on: each == _reach),
onTap: () {
setState(() => _reach = each);
widget.onReach(each);
},
),
if (widget.offersPrivate)
RoundedRow(
icon: LucideIcons.lock,
title: l10n.assistantScopePrivate,
trailing: Switch(
value: _includePrivate && _reach != AssistantReach.ticked,
onChanged: _reach == AssistantReach.ticked
? null
: _setPrivate,
),
],
),
onTap: _reach == AssistantReach.ticked
? null
: () => _setPrivate(!_includePrivate),
),
],
),
if (widget.offersPrivate)
CheckboxListTile(
value: _includePrivate && _reach != AssistantReach.ticked,
onChanged: _reach == AssistantReach.ticked
? null
: (on) {
setState(() => _includePrivate = on ?? false);
widget.onIncludePrivate(on ?? false);
},
title: Text(l10n.assistantScopePrivate),
),
],
if (widget.parts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(l10n.assistantContextParts, style: heading),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final part in widget.parts)
Tooltip(
// An image's text is its asset row's id.
message: part.kind == AssistantPartKind.image
? partText(l10n, part)
: part.sent,
waitDuration: const Duration(milliseconds: 400),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
),
child: FilterChip(
avatar: part.kind == AssistantPartKind.image
? _Thumbnail(assetId: part.text)
: null,
label: Text(
partText(l10n, part),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
selected: _chosen.contains(part),
onSelected: (_) {
setState(
() => _chosen.contains(part)
? _chosen.remove(part)
: _chosen.add(part),
);
widget.onToggle(part);
},
),
),
),
],
),
),
RoundedGroup(
children: [
for (final part in widget.parts)
Tooltip(
// An image's text is its asset row's id.
message: part.kind == AssistantPartKind.image
? partText(l10n, part)
: part.sent,
waitDuration: const Duration(milliseconds: 400),
child: RoundedRow(
icon: LucideIcons.textQuote,
leading: part.kind == AssistantPartKind.image
? SizedBox.square(
dimension: 40,
child: _Thumbnail(assetId: part.text),
)
: null,
title: partText(l10n, part),
trailing: _Check(on: _chosen.contains(part)),
onTap: () {
setState(
() => _chosen.contains(part)
? _chosen.remove(part)
: _chosen.add(part),
);
widget.onToggle(part);
},
),
),
],
),
],
const SizedBox(height: 16),
@ -756,23 +829,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,
),
],
),
),
),
),
@ -817,7 +897,19 @@ class const _AnswererLabel({required final Answerer answerer})
}
}
/// A card's image on its chip, small.
/// A tick at a row's end while it is chosen ([on]); its room kept
/// otherwise, so that rows don't shift.
class const _Check({required final bool on}) extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 24,
child: on
? Icon(LucideIcons.check, color: Theme.of(context).colorScheme.primary)
: null,
);
}
/// A card's image on its row, small.
class const _Thumbnail({required final String assetId})
extends StatelessWidget {
@override
@ -983,13 +1075,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

@ -33,9 +33,10 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsAskAssistant),
subtitle: Text(l10n.settingsAskAssistantSubtitle),
isThreeLine: true,
title: ExplainedTitle(
l10n.settingsAskAssistant,
help: l10n.settingsAskAssistantSubtitle,
),
value: on,
onChanged: (on) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAssistantChosen(on),

View file

@ -1,36 +1,61 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_attachments.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
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 + below a question's field: picks files to go along, read on the
/// device ([AssistantAttachments]), images only where the assistant
/// [assistant] reads [images]; each read hands its part to [onAttached],
/// each refused says why.
/// 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,
);
/// 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).
typedef AddRow = ({
IconData icon,
String title,
String? value,
VoidCallback onTap,
});
/// 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
/// ([AssistantAttachments]), each read handing its part to [onAttached],
/// each refused saying why.
class const AttachButton({
required final bool images,
required final String assistant,
required final ValueChanged<AssistantPart> onAttached,
final List<AddRow> rows = const [],
super.key,
}) extends StatelessWidget {
Future<void> _attach(BuildContext context) async {
/// Reads the [picked] files, as images only where the assistant reads
/// them.
Future<void> _read(BuildContext context, List<PickedFile> picked) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final dependencies = Dependencies.of(context);
final picked = await dependencies.files.pickMany(
label: l10n.assistantAttachFiles,
extensions: AssistantAttachments.extensionsFor(images: images),
);
final reader = AssistantAttachments(dependencies.pdfEngine);
final reader = AssistantAttachments(Dependencies.of(context).pdfEngine);
for (final file in picked) {
final read = await reader.read(file, images: images);
if (read.part case final part?) {
@ -56,14 +81,219 @@ class const AttachButton({
}
}
Future<void> _open(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final dependencies = Dependencies.of(context);
final photos = dependencies.photos;
final chosen = await showAdaptiveSheet<_Source>(
context,
builder: (sheet) => _AddSheet(
sources: [
if (images && photos.hasCamera) _Source.camera,
if (images) _Source.photos,
_Source.files,
],
rows: rows,
),
);
if (chosen == null || !context.mounted) {
return;
}
final picked = switch (chosen) {
_Source.camera => [?await photos.take()],
_Source.photos => await photos.pick(),
_Source.files => await dependencies.files.pickMany(
label: l10n.assistantAttachFiles,
extensions: AssistantAttachments.extensionsFor(images: images),
),
};
if (context.mounted) {
await _read(context, picked);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return IconButton(
tooltip: images ? l10n.assistantAttach : l10n.assistantAttachFile,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.plus),
onPressed: () => unawaited(_attach(context)),
style: boxedButtonStyle(context),
icon: const Icon(LucideIcons.plus, size: 20),
onPressed: () => unawaited(_open(context)),
);
}
}
/// Where what + adds comes from.
enum _Source() {
camera,
photos,
files,
}
/// +'s sheet: the [sources] as tiles side by side, then the [rows] in one
/// rounded group. A tile closes it with its source; a row closes it and
/// leads on.
class const _AddSheet({
required final List<_Source> sources,
required final List<AddRow> rows,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.assistantAddTitle,
body: Column(
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 (rows.isNotEmpty) ...[
const SizedBox(height: 12),
RoundedGroup(
children: [
for (final row in rows)
RoundedRow(
icon: row.icon,
title: row.title,
value: row.value,
onTap: () {
Navigator.of(context).pop();
row.onTap();
},
),
],
),
],
const SizedBox(height: 16),
],
),
);
}
}
/// A big tile of +'s sheet: its icon in a circle above its [label].
class const _Tile({
required final IconData icon,
required final String label,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
CircleAvatar(
radius: 24,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
child: Icon(icon, size: 22),
),
const SizedBox(height: 8),
Text(
label,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
/// Rows set apart as one rounded group, as Claude's sheets have them: a
/// tile a shade above the sheet, its first and last corners rounded, a
/// hairline of the sheet between rows.
class const RoundedGroup({required final List<Widget> children, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, child) in children.indexed) ...[
if (index > 0) const SizedBox(height: 2),
Material(
color: theme.colorScheme.surfaceContainerHigh,
child: child,
),
],
],
),
);
}
}
/// A row of a [RoundedGroup]: its icon in a circle, its [title], beneath it
/// its [value] where it has one, and at its end [trailing], a chevron where
/// none is given.
class const RoundedRow({
required final IconData icon,
required final String title,
/// Shown in the icon's place, as a picture's thumbnail.
final Widget? leading,
final String? value,
final Widget? trailing,
final VoidCallback? onTap,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
onTap: onTap,
leading:
leading ??
CircleAvatar(
radius: 20,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
child: Icon(icon, size: 20),
),
title: Text(title),
subtitle: value == null ? null : Text(value!),
trailing:
trailing ??
Icon(
LucideIcons.chevronRight,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}
@ -125,6 +355,10 @@ class const SpeakOrSend({
required final VoidCallback onSend,
required final String sendTooltip,
final IconData sendIcon = LucideIcons.arrowUp,
/// Opens voice mode (ADR 0021, D68), offered beside the microphone while
/// nothing could be sent, as Claude's field does; none where null.
final VoidCallback? onTalk,
super.key,
}) extends StatefulWidget {
@override
@ -205,10 +439,21 @@ 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,
] else if (widget.onTalk case final talk?) ...[
const SizedBox(width: 6),
IconButton.filled(
tooltip: l10n.assistantTalk,
icon: const Icon(LucideIcons.audioLines, size: 20),
onPressed: talk,
),
],
],
);
},

View file

@ -288,7 +288,7 @@ class const _ConversationView({
view: TurnView.of(asking.messages),
streaming: asking.text,
approving: switch (asking.approving) {
final image? => _ImageApproval(
final image? => ImageApproval(
name: name,
image: image,
),
@ -507,9 +507,10 @@ class const _Turn({
/// The image [name] asks to see (D12), shown only once the person
/// approves it: Show it, or Don't show.
class const _ImageApproval({
class const ImageApproval({
required final String name,
required final AskedImage image,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {

View file

@ -0,0 +1,580 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_output.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/asking.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/composer.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens voice mode (ADR 0021, D68), as Claude's "Let's talk": over the
/// whole screen, a conversation with [choice] within [scope] spoken rather
/// than typed, the first question going with the parts [chosen], at
/// [effort]. Any other assistant among [assistants] can be picked, which
/// starts a conversation with it. Completes once it ends.
Future<void> showVoiceMode(
BuildContext context, {
required AssistantChoice choice,
required List<AssistantChoice> assistants,
required AssistantScope scope,
List<AssistantPart> chosen = const [],
LlmEffort? effort,
}) => Navigator.of(context, rootNavigator: true).push(
PageRouteBuilder<void>(
pageBuilder: (context, _, _) => _VoiceMode(
choice: choice,
assistants: assistants,
scope: scope,
chosen: chosen,
effort: effort,
),
transitionsBuilder: (context, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
),
);
/// Where voice mode stands.
enum _Phase() {
/// Waiting for the microphone to be tapped.
idle,
/// Hearing the person.
listening,
/// Waiting for the answer.
thinking,
/// Reading the answer aloud.
speaking,
}
class const _VoiceMode({
required final AssistantChoice choice,
required final List<AssistantChoice> assistants,
required final AssistantScope scope,
required final List<AssistantPart> chosen,
required final LlmEffort? effort,
}) extends StatefulWidget {
@override
State<_VoiceMode> createState() => _VoiceModeState();
}
class _VoiceModeState() extends State<_VoiceMode> {
late final _speech = Dependencies.of(context).speech;
late final _output = Dependencies.of(context).speechOutput;
late var _choice = widget.choice;
late ConversationCubit _cubit = _conversationWith(_choice);
/// The parts chosen in the field, which go with the first question only.
late var _chosen = widget.chosen;
/// The files and photos + attached, for the next question.
final _attached = <AssistantPart>[];
var _phase = _Phase.idle;
/// What was heard of the question being spoken.
var _heard = '';
/// The last answer, as written.
var _answer = '';
/// Whether answers are read aloud.
var _speaker = true;
/// Whether voice mode is ending, after which nothing listens or reads.
var _ending = false;
String? get _language => Localizations.localeOf(context).toLanguageTag();
ConversationCubit _conversationWith(AssistantChoice choice) {
final person = PersonDependencies.of(context);
return ConversationCubit(
assistants: Dependencies.of(context).assistants,
lookups: person.assistantLookups,
conversations: person.conversations,
choice: choice,
assistant: assistantRefOf(choice),
scope: widget.scope,
answerIn: Localizations.localeOf(context).toLanguageTag(),
effort: choice == widget.choice ? widget.effort : null,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_phase == _Phase.idle && !_started) {
_started = true;
unawaited(_start());
}
}
var _started = false;
Future<void> _start() async {
await _cubit.open();
if (mounted) {
await _listen();
}
}
/// Listens for a question, stopping any reading first.
Future<void> _listen() async {
if (_ending) {
return;
}
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
// Listening before the reading stops, so the reading ending doesn't
// listen a second time.
setState(() {
_phase = _Phase.listening;
_heard = '';
});
await _output.stop();
if (!mounted || _ending) {
return;
}
final listens = await _speech.listen(
language: _language,
onWords: (words) {
if (mounted && _phase == _Phase.listening) {
setState(() => _heard = words);
}
},
onDone: () => unawaited(_heardAll()),
);
if (!listens && mounted) {
setState(() => _phase = _Phase.idle);
messenger.showSnackBar(
SnackBar(content: Text(l10n.assistantSpeechUnavailable)),
);
}
}
/// Asks what was heard, then reads the answer aloud and listens again;
/// with nothing heard, waits for the microphone.
Future<void> _heardAll() async {
if (!mounted || _ending || _phase != _Phase.listening) {
return;
}
final question = _heard.trim();
if (question.isEmpty && _attached.isEmpty) {
setState(() => _phase = _Phase.idle);
return;
}
final sent = [..._chosen, ..._attached];
setState(() {
_phase = _Phase.thinking;
_answer = '';
_chosen = const [];
_attached.clear();
});
await _cubit.ask(question, sent);
if (!mounted || _ending) {
return;
}
final state = _cubit.state;
if (state.unanswered != null || state.turns.isEmpty) {
setState(() => _phase = _Phase.idle);
return;
}
final answer = TurnView.of(state.turns.last.messages).answer;
setState(() => _answer = answer);
if (_speaker && _output.supported) {
setState(() => _phase = _Phase.speaking);
await _output.speak(speakableText(answer), language: _language);
// Tapping the microphone meanwhile listens already.
if (!mounted || _phase != _Phase.speaking) {
return;
}
}
await _listen();
}
/// The microphone: stops listening, which asks what was heard; while the
/// answer is read or awaited, or while waiting, listens at once.
Future<void> _microphone() async {
switch (_phase) {
case _Phase.listening:
await _speech.stop();
case _Phase.speaking || _Phase.idle:
await _listen();
case _Phase.thinking:
await _cubit.stop();
await _listen();
}
}
Future<void> _toggleSpeaker() async {
setState(() => _speaker = !_speaker);
if (!_speaker && _phase == _Phase.speaking) {
await _output.stop();
await _listen();
}
}
/// Starts a conversation with [choice] instead, once it may be asked.
Future<void> _pick(AssistantChoice choice) async {
if (choice == _choice ||
!await readyToAsk(context, choice: choice, scope: widget.scope) ||
!mounted) {
return;
}
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAskedWith(AssistantAnswerer(choice).key),
);
// Idle first, so stopping asks nothing of the one before.
setState(() => _phase = _Phase.idle);
await _speech.stop();
await _output.stop();
if (!mounted) {
return;
}
final previous = _cubit;
setState(() {
_choice = choice;
_cubit = _conversationWith(choice);
_answer = '';
_heard = '';
});
await previous.close();
await _start();
}
Future<void> _end() async {
_ending = true;
await _speech.stop();
await _output.stop();
if (mounted) {
Navigator.of(context).pop();
}
}
@override
void dispose() {
_ending = true;
unawaited(_speech.stop());
unawaited(_output.stop());
unawaited(_cubit.close());
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// What was heard while it's said, the answer otherwise, written as it
// is read; beneath it, where voice mode stands.
final said = switch (_phase) {
_Phase.listening when _heard.isNotEmpty => _heard,
_Phase.thinking => _heard,
_ => speakableText(_answer),
};
final hint = switch (_phase) {
_Phase.listening when _heard.isEmpty => l10n.voiceListening,
_Phase.thinking => l10n.voiceThinking,
_Phase.idle => l10n.voiceTapToTalk,
_ => null,
};
return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
_ending = true;
}
},
child: BlocProvider.value(
value: _cubit,
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: Stack(
children: [
Positioned.fill(child: _Glow(phase: _phase)),
SafeArea(
child: Column(
children: [
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
ShaderMask(
shaderCallback: voiceGradient.createShader,
child: const Icon(
LucideIcons.sparkles,
size: 48,
color: Colors.white,
),
),
const SizedBox(height: 16),
Text(
l10n.voiceTitle,
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (said.isNotEmpty)
Text(
said,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
if (hint != null) ...[
const SizedBox(height: 12),
Text(
hint,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
BlocBuilder<ConversationCubit, ConversationState>(
builder: (context, state) =>
switch (state.asking?.approving) {
final image? => Padding(
padding: const EdgeInsets.only(top: 16),
child: ImageApproval(
name: _choice.name,
image: image,
),
),
null => const SizedBox.shrink(),
},
),
],
),
),
),
),
_Microphone(phase: _phase, onPressed: _microphone),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
children: [
Badge(
isLabelVisible: _attached.isNotEmpty,
label: Text('${_attached.length}'),
child: AttachButton(
images: _choice.images,
assistant: _choice.name,
onAttached: (part) =>
setState(() => _attached.add(part)),
),
),
const SizedBox(width: 8),
Flexible(
child: _Picker(
current: _choice,
assistants: widget.assistants,
onPicked: (choice) => unawaited(_pick(choice)),
),
),
const Spacer(),
IconButton(
tooltip: _speaker
? l10n.voiceSpeakerOff
: l10n.voiceSpeakerOn,
style: _round(context),
icon: Icon(
_speaker
? LucideIcons.volume2
: LucideIcons.volumeX,
),
onPressed: () => unawaited(_toggleSpeaker()),
),
const SizedBox(width: 8),
IconButton(
tooltip: l10n.voiceEnd,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.inverseSurface,
foregroundColor:
theme.colorScheme.onInverseSurface,
fixedSize: const Size.square(48),
),
icon: const Icon(LucideIcons.x),
onPressed: () => unawaited(_end()),
),
],
),
),
],
),
),
],
),
),
),
);
}
}
/// The colours of voice mode's sign and glow: the assistant's ring's.
const voiceGradient = LinearGradient(
colors: [Color(0xFF179FD9), Color(0xFF415DF0), Color(0xFF8080FF)],
);
/// A round button of voice mode's row.
ButtonStyle _round(BuildContext context) => IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
fixedSize: const Size.square(48),
);
/// The glow rising from the foot of the screen, brighter while listening
/// or reading aloud.
class const _Glow({required final _Phase phase}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final strength = switch (phase) {
_Phase.listening => 0.55,
_Phase.speaking => 0.45,
_Phase.thinking => 0.3,
_Phase.idle => 0.15,
};
return IgnorePointer(
child: TweenAnimationBuilder<double>(
tween: Tween(end: strength),
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOut,
builder: (context, strength, _) => DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(0, 1.35),
radius: 1.1,
colors: [
const Color(0xFF415DF0).withValues(alpha: strength),
const Color(0xFF8080FF).withValues(alpha: strength / 3),
Colors.transparent,
],
stops: const [0, 0.45, 1],
),
),
),
),
);
}
}
/// The big microphone: listening, tapped, it stops and the question goes;
/// otherwise it listens, stopping the reading or the answer on its way.
class const _Microphone({
required final _Phase phase,
required final VoidCallback onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final listening = phase == _Phase.listening;
return Tooltip(
message: listening ? l10n.voiceDone : l10n.voiceTalk,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
decoration: ShapeDecoration(
shape: const CircleBorder(),
color: listening
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHigh,
),
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Icon(
listening ? LucideIcons.audioLines : LucideIcons.mic,
size: 28,
color: listening
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
),
),
),
);
}
}
/// Who answers, as a round chip with its name, its menu the other
/// assistants.
class const _Picker({
required final AssistantChoice current,
required final List<AssistantChoice> assistants,
required final ValueChanged<AssistantChoice> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final label = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
current.name,
style: theme.textTheme.labelLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (assistants.length > 1) ...[
const SizedBox(width: 4),
Icon(
LucideIcons.chevronsUpDown,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
);
return MenuAnchor(
menuChildren: [
for (final choice in assistants)
MenuItemButton(
trailingIcon: choice == current
? const Icon(LucideIcons.check)
: null,
onPressed: () => onPicked(choice),
child: Text(choice.name),
),
],
builder: (context, controller, _) => Tooltip(
message: l10n.assistantChoose,
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: assistants.length < 2
? null
: () => controller.isOpen
? controller.close()
: controller.open(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: label,
),
),
),
),
);
}
}

View file

@ -186,15 +186,14 @@ class const _SettingsCard() extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
ExplainedTitle(
l10n.settingsDeviceDictionaryTitle,
help: l10n.settingsDeviceDictionaryAbout,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(l10n.settingsDeviceDictionaryAbout),
const SizedBox(height: 12),
const SizedBox(height: 8),
if (dictionaries.progress case final progress?)
DeviceDictionaryProgressView(progress: progress)
else if (installed != null)

View file

@ -256,8 +256,10 @@ class _ExportSheetState() extends State<_ExportSheet> {
value: _withProgress,
onChanged: (value) =>
setState(() => _withProgress = value ?? _withProgress),
title: Text(l10n.exportWithProgress),
subtitle: Text(l10n.exportWithProgressSubtitle),
title: ExplainedTitle(
l10n.exportWithProgress,
help: l10n.exportWithProgressSubtitle,
),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),

View file

@ -159,15 +159,16 @@ class _JellyfinBrowserScreenState() extends State<JellyfinBrowserScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.jellyfinSubtitle),
const SizedBox(height: 4),
Text(
l10n.jellyfinPrivacy,
style: theme.textTheme.bodySmall?.copyWith(
ExplainedTitle(
l10n.jellyfinTitle,
help:
'${l10n.jellyfinSubtitle}\n\n'
'${l10n.jellyfinPrivacy}',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
const JellyfinConnectForm(),
],
),

View file

@ -14,22 +14,14 @@ class const JellyfinSettingsCard({super.key}) extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
ExplainedTitle(
l10n.jellyfinTitle,
help: '${l10n.jellyfinSubtitle}\n\n${l10n.jellyfinPrivacy}',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(l10n.jellyfinSubtitle),
const SizedBox(height: 4),
Text(
l10n.jellyfinPrivacy,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
const JellyfinAccountView(),
],
),

View file

@ -30,20 +30,14 @@ class const CitationSettingsCard({super.key}) extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
ExplainedTitle(
l10n.settingsCitations,
help: l10n.settingsCitationsDescription,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
l10n.settingsCitationsDescription,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
DropdownButtonFormField<CitationStyle>(
initialValue: style,
// A style's name is cut short rather than overflowing a phone.

View file

@ -177,9 +177,10 @@ class const _PrivacyCard() extends StatelessWidget {
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsCountLearning),
subtitle: Text(l10n.settingsCountLearningSubtitle),
isThreeLine: true,
title: ExplainedTitle(
l10n.settingsCountLearning,
help: l10n.settingsCountLearningSubtitle,
),
value: counted ?? false,
onChanged: counted == null
? null
@ -221,8 +222,10 @@ class const _StudyCard() extends StatelessWidget {
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsStudyPomodoroTitle),
subtitle: Text(l10n.settingsStudyPomodoroSubtitle),
title: ExplainedTitle(
l10n.settingsStudyPomodoroTitle,
help: l10n.settingsStudyPomodoroSubtitle,
),
value: context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.pomodoroWhileStudying,
),
@ -270,9 +273,10 @@ class _WritingCardState() extends State<_WritingCard> {
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsGrammarTitle),
subtitle: Text(l10n.settingsGrammarSubtitle),
isThreeLine: true,
title: ExplainedTitle(
l10n.settingsGrammarTitle,
help: l10n.settingsGrammarSubtitle,
),
value: context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.checkGrammar,
),

Some files were not shown because too many files have changed in this diff Show more