refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text

Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are
halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so
Meditate's dream journal (ADR 0038, D15) listens through the same code.
Learn imports them from there and behaves as before: its composer and
its tests are unchanged but for the imports, and `DeviceSpeechInput()`
asks for what it asked before.

`DeviceSpeechInput` takes three options, off by default:
- `preferOnDevice`: on Android, recognition on the device where it can
  (Android 12 on), else offline; should that fail before a word is
  heard (not a pause), it listens as Android otherwise does from then
  on.
- `pauseFor`: how long a pause may last before the recogniser stops.
- `listenFor`: how long one listen may last at most.

`Dictation` writes what is heard where the cursor stands
(`DictationInsertion`: in place of a selection, set apart by a space),
shows the words so far, and listens again after each pause while it is
on, stopping by itself after three listens running heard nothing.

speech_to_text moves from Learn's pubspec to halkyon_app's, the lock
unchanged. Todo, Train and Meditate link the plugin now; their
generated plugin registrants (macOS, Windows) say so. An app declares
the microphone itself, and Todo and Train declare none, so nothing
there can listen.

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 14:41:07 +01:00
commit 603218ef96
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 405 additions and 16 deletions

View file

@ -10,7 +10,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput` on `speech_to_text`, `FakeSpeechInput` in `testing.dart`; none on Linux) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.

File diff suppressed because one or more lines are too long

View file

@ -27,7 +27,6 @@ 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/speech_input.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';

View file

@ -132,9 +132,6 @@ dependencies:
serverpod_auth_idp_flutter: 4.0.4
serverpod_flutter: 4.0.4
serverpod_offline_sync_client: 0.0.10
# A question spoken to the assistant (ADR 0021, D67): the device's own speech
# recognition (Android's, Apple's, Windows'); none on Linux.
speech_to_text: ^7.5.0
# Anki collections are SQLite databases (the sync engine already bundles it).
sqlite3: ^3.6.0
url_launcher: ^6.3.2

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart' show FakeSpeechInput;
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -17,7 +18,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_speech_input.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_device_settings_store.dart';
import '../../support/pump_app.dart';

View file

@ -24,7 +24,6 @@ import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.d
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/speech_input.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';
@ -101,7 +100,6 @@ import 'fake_publish_gateway.dart';
import 'fake_review_gateway.dart';
import 'fake_settings_gateway.dart';
import 'fake_sound_player.dart';
import 'fake_speech_input.dart';
import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
import 'fake_youtube_videos.dart';

View file

@ -15,6 +15,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import wakelock_plus
import window_to_front
@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))

View file

@ -11,6 +11,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)

View file

@ -15,6 +15,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import window_to_front
@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
}

View file

@ -10,6 +10,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -22,6 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)

View file

@ -14,6 +14,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import window_to_front
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
}

View file

@ -10,6 +10,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -22,6 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)

View file

@ -176,6 +176,8 @@ export 'src/simplelogin/widget/simplelogin_section.dart';
export 'src/simplelogin/widget/simplelogin_sheet.dart';
export 'src/simplelogin/widget/simplelogin_standing_scope.dart';
export 'src/simplelogin/widget/simplelogin_texts.dart';
export 'src/speech/dictation.dart';
export 'src/speech/speech_input.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';
export 'src/sync/pending_changes_store.dart';

View file

@ -0,0 +1,159 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/speech/speech_input.dart';
/// Where the words of one listen go in a text (Halkyon Meditate's ADR 0038,
/// D15): in place of what is selected, or where the cursor stands (at the
/// end where the field has none yet), set apart from the text on either
/// side by a space where it has none there.
final class DictationInsertion._(
final String _before,
final String _after,
) {
/// Where words go in [value], as it stands when listening begins.
factory at(TextEditingValue value) {
final text = value.text;
final selection = value.selection;
final valid =
selection.isValid &&
selection.start >= 0 &&
selection.end <= text.length;
return DictationInsertion._(
valid ? text.substring(0, selection.start) : text,
valid ? text.substring(selection.end) : '',
);
}
/// The text with [words] put in, the cursor after them; as it was where
/// [words] is empty.
TextEditingValue withWords(String words) {
if (words.isEmpty) {
final text = '$_before$_after';
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: _before.length),
);
}
final lead = _before.isEmpty || _endsInSpace(_before) ? '' : ' ';
final trail = _after.isEmpty || _startsInSpace(_after) ? '' : ' ';
final head = '$_before$lead$words';
return TextEditingValue(
text: '$head$trail$_after',
selection: TextSelection.collapsed(offset: head.length),
);
}
static bool _endsInSpace(String text) =>
text.trimRight().length != text.length;
static bool _startsInSpace(String text) =>
text.trimLeft().length != text.length;
}
/// Dictation into [_text] (Halkyon Meditate's ADR 0038, D15): what is said
/// goes where the cursor stands ([DictationInsertion]) as it is heard, and
/// [heard] holds the listen's words so far, to show. A recogniser stops
/// after a pause, so while the person leaves it on, it listens again at
/// once, the next words after the last, until it has heard nothing
/// [silencesBeforeStopping] times running or the person stops it.
class Dictation(
final SpeechInput _speech,
final TextEditingController _text,
) extends ChangeNotifier {
/// How many listens running may hear nothing before it stops by itself:
/// a dream remembered may need a moment, a phone left on the table not.
static const silencesBeforeStopping = 3;
var _on = false;
var _heard = '';
var _silences = 0;
String? _language;
/// Counts the listens, so a listen ended late tells nothing.
var _listen = 0;
/// Whether this kind of device recognises speech at all.
bool get supported => _speech.supported;
/// Whether it is on: listening, or about to again after a pause.
bool get listening => _on;
/// The words of the listen under way so far.
String get heard => _heard;
/// Starts listening in [language] (the device's own where null); false,
/// and off again, where the device can't listen.
Future<bool> start({required String? language}) async {
if (_on) {
return true;
}
_on = true;
_silences = 0;
_language = language;
notifyListeners();
return await _listenOnce();
}
Future<bool> _listenOnce() async {
final listen = ++_listen;
final insertion = DictationInsertion.at(_text.value);
_heard = '';
final listens = await _speech.listen(
language: _language,
onWords: (words) {
if (listen == _listen && _on) {
_heard = words;
_text.value = insertion.withWords(words);
notifyListeners();
}
},
onDone: () {
if (listen == _listen && _on) {
unawaited(_paused());
}
},
);
if (!listens && listen == _listen) {
_off();
}
return listens;
}
/// A listen ended by itself: again, unless it keeps hearing nothing.
Future<void> _paused() async {
_silences = _heard.isEmpty ? _silences + 1 : 0;
if (_silences >= silencesBeforeStopping) {
_off();
return;
}
// Words heard stand as they are; the next go after them.
await _listenOnce();
}
void _off() {
_on = false;
_heard = '';
_listen++;
notifyListeners();
}
/// Stops listening, keeping what was written.
Future<void> stop() async {
if (!_on) {
return;
}
_off();
await _speech.stop();
}
@override
void dispose() {
if (_on) {
_on = false;
_listen++;
unawaited(_speech.stop());
}
super.dispose();
}
}

View file

@ -0,0 +1,147 @@
import 'package:flutter/foundation.dart';
import 'package:speech_to_text/speech_to_text.dart';
/// Turns what the person says into words (Halkyon Learn's ADR 0021, D67;
/// Halkyon Meditate's ADR 0038, D15), through the device's own speech
/// recognition: Android's (which may send the sound to Google or the
/// phone's maker), Apple's, Windows'. The instance never hears it.
abstract interface class SpeechInput() {
/// Whether this kind of device recognises speech at all; Linux doesn't.
/// Asks nothing, so the microphone's permission is only asked for once
/// the person taps it.
bool get supported;
/// Listens in [language] (a BCP 47 tag; the device's own language where
/// it has none of that language), handing on all it heard so far to
/// [onWords] as it hears it, until it stops hearing anything or [stop]
/// is called, then calls [onDone]. False, and nothing heard, where the
/// device can't listen now: no recogniser, or the microphone refused.
Future<bool> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
});
/// Stops listening, keeping what was heard.
Future<void> stop();
}
/// [SpeechInput] through `speech_to_text`.
class DeviceSpeechInput({
/// Whether Android is asked to recognise on the device (Android 12 on,
/// where it can; offline before), the sound leaving it no more: where that
/// fails before a word is heard, it listens as Android otherwise does
/// from then on. Apple's and Windows' recognisers choose for themselves.
final bool preferOnDevice = false,
/// How long a pause may last before the recogniser stops; the platform's
/// own where null, and Android's is shorter whatever is asked.
final Duration? pauseFor,
/// How long one listen may last at most; the platform's own where null.
final Duration? listenFor,
}) implements SpeechInput {
final _speech = SpeechToText();
/// Called once listening ends, for the listen under way.
VoidCallback? _onDone;
/// Whether the listen under way heard a word.
var _heard = false;
/// Whether recognising on the device failed here, so it is asked no more.
var _onDeviceFailed = false;
/// Whether the listen under way asked for recognition on the device.
var _onDevice = false;
@override
bool get supported => switch (defaultTargetPlatform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.macOS ||
TargetPlatform.windows => true,
TargetPlatform.linux || TargetPlatform.fuchsia => false,
};
@override
Future<bool> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async {
if (!supported ||
!await _speech.initialize(
onStatus: (status) {
if (status == SpeechToText.doneStatus ||
status == SpeechToText.notListeningStatus) {
_done();
}
},
onError: (error) {
// A pause isn't a failure of the device's recogniser.
if (_onDevice &&
!_heard &&
!const {
'error_no_match',
'error_speech_timeout',
}.contains(error.errorMsg)) {
_onDeviceFailed = true;
}
_done();
},
)) {
return false;
}
_onDone = onDone;
_heard = false;
_onDevice =
preferOnDevice &&
!_onDeviceFailed &&
defaultTargetPlatform == TargetPlatform.android;
await _speech.listen(
onResult: (result) {
_heard = _heard || result.recognizedWords.isNotEmpty;
onWords(result.recognizedWords);
if (result.finalResult) {
_done();
}
},
listenOptions: SpeechListenOptions(
localeId: language == null ? null : await _localeOf(language),
listenMode: ListenMode.dictation,
cancelOnError: true,
onDevice: _onDevice,
pauseFor: pauseFor,
listenFor: listenFor,
),
);
return true;
}
void _done() {
final done = _onDone;
_onDone = null;
done?.call();
}
/// The recogniser's locale for [language]: the same tag, else the first
/// of its language; null where it has none, for the device's own.
Future<String?> _localeOf(String language) async {
String tag(String id) => id.replaceAll('_', '-').toLowerCase();
final wanted = tag(language);
final primary = wanted.split('-').first;
final locales = [
for (final locale in await _speech.locales()) locale.localeId,
];
return locales.where((id) => tag(id) == wanted).firstOrNull ??
locales.where((id) => tag(id).split('-').first == primary).firstOrNull;
}
@override
Future<void> stop() async {
if (_speech.isListening) {
await _speech.stop();
}
}
}

View file

@ -0,0 +1,51 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/src/speech/speech_input.dart';
/// A [SpeechInput] the test speaks into: [say] hands words on, [finish]
/// ends the listen.
class FakeSpeechInput() implements SpeechInput {
@override
bool supported = true;
/// Whether the device can listen when asked: false as if the microphone
/// were refused.
bool listens = true;
/// The languages asked to listen in, in order.
final languages = <String?>[];
ValueChanged<String>? _onWords;
VoidCallback? _onDone;
/// Whether a listen is under way.
bool get listening => _onWords != null;
/// Hands on [words], as all heard so far.
void say(String words) => _onWords?.call(words);
/// Ends the listen, as the recogniser does after a pause.
void finish() {
final done = _onDone;
_onWords = null;
_onDone = null;
done?.call();
}
@override
Future<bool> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async {
languages.add(language);
if (!listens) {
return false;
}
_onWords = onWords;
_onDone = onDone;
return true;
}
@override
Future<void> stop() async => finish();
}

View file

@ -20,6 +20,7 @@ export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_public_profile_gateway.dart';
export 'src/testing/fake_screen_guard.dart';
export 'src/testing/fake_simplelogin_gateway.dart';
export 'src/testing/fake_speech_input.dart';
export 'src/testing/memory_account_key_store.dart';
export 'src/testing/memory_account_wait_store.dart';
export 'src/testing/memory_file_saver.dart';

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