Merge branch 'learn-fb' into integration
This commit is contained in:
commit
918080ce46
42 changed files with 2471 additions and 368 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue