feat(learn): voice mode, a conversation spoken as Claude's "Let's talk" (ADR 0021, D68)

Beside the field's microphone, a button opens voice mode over the whole
screen: a glow rising from below, a big microphone, and +, who answers,
the speaker and End in one row. It listens with the device's recogniser,
asks what was heard in one kept conversation, reads the answer aloud
with the device's speech (flutter_tts, its Markdown left out) and
listens again. On Linux there is none, as there is no microphone; where
nothing reads aloud, answers are shown as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 22:11:28 +01:00
commit c984678c5b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1180 additions and 6 deletions

View file

@ -0,0 +1,40 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_output.dart';
/// A [SpeechOutput] that notes what it reads; while [holds], a reading
/// lasts until [finish].
class FakeSpeechOutput() implements SpeechOutput {
@override
bool supported = true;
/// Whether a reading lasts until [finish] rather than ending at once.
bool holds = false;
/// What was read aloud, in order, with the language asked for.
final spoken = <(String, String?)>[];
Completer<void>? _reading;
/// Whether a reading is under way.
bool get speaking => _reading != null;
/// Ends the reading under way.
void finish() {
final reading = _reading;
_reading = null;
reading?.complete();
}
@override
Future<void> speak(String text, {String? language}) async {
spoken.add((text, language));
if (holds) {
final reading = _reading = Completer<void>();
await reading.future;
}
}
@override
Future<void> stop() async => finish();
}

View file

@ -25,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files
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/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -102,6 +103,7 @@ import 'fake_publish_gateway.dart';
import 'fake_review_gateway.dart';
import 'fake_settings_gateway.dart';
import 'fake_sound_player.dart';
import 'fake_speech_output.dart';
import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
import 'fake_youtube_videos.dart';
@ -175,6 +177,7 @@ Dependencies testDependencies({
WebSearch? webSearch,
SpeechInput? speech,
PhotoPicker? photos,
SpeechOutput? speechOutput,
JellyfinGateway? jellyfinGateway,
JellyfinStore? jellyfinStore,
JellyfinSuggestion? jellyfinSuggestion,
@ -268,6 +271,7 @@ Dependencies testDependencies({
),
speech: speech ?? FakeSpeechInput(),
photos: photos ?? FakePhotoPicker(),
speechOutput: speechOutput ?? FakeSpeechOutput(),
jellyfin: JellyfinRepository(
(device) => jellyfinGateway ?? FakeJellyfinGateway(),
jellyfinStore ?? MemoryJellyfinStore(),