diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a7f5602..a314afd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,71 +1,84 @@ -import UIKit import Flutter import Speech +import UIKit + @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { - private func receiveTxRequest(call: FlutterMethodCall, result: @escaping FlutterResult) { - let uri = (call.arguments as! [String:Any])["path"] as! String - let url = URL(fileURLWithPath: uri) - guard let myRecognizer = SFSpeechRecognizer() else { - // A recognizer is not supported for the current locale - result(FlutterError(code: "FAILED_REC", message: "unsupported locale", details: nil)) - return - } - - if !myRecognizer.isAvailable { - result(FlutterError(code: "FAILED_REC", message: "unavailable recognizer", details: nil)) - return - } + let uri = (call.arguments as! [String: Any])["path"] as! String + let localeId = (call.arguments as! [String: Any])["locale"] as! String + let url = URL(fileURLWithPath: uri) + guard let myRecognizer = SFSpeechRecognizer(locale: Locale.init(identifier: localeId)) else { + // A recognizer is not supported for the current locale + result(FlutterError(code: "FAILED_REC", message: "unsupported locale", details: nil)) + return + } + + if !myRecognizer.isAvailable { + result(FlutterError(code: "FAILED_REC", message: "unavailable recognizer", details: nil)) + return + } - let request = SFSpeechURLRecognitionRequest(url: url) - myRecognizer.recognitionTask(with: request) { (res, error) in - guard let res = res else { - // Recognition failed, so check error for details and handle it - result(FlutterError(code: "FAILED_REC", message: error.debugDescription, details: nil)) - return - } + let request = SFSpeechURLRecognitionRequest(url: url) + myRecognizer.recognitionTask(with: request) { (res, error) in + guard let res = res else { + // Recognition failed, so check error for details and handle it + result(FlutterError(code: "FAILED_REC", message: error.debugDescription, details: nil)) + return + } - // Print the speech that has been recognized so far - if res.isFinal { - result(String(res.bestTranscription.formattedString)) - } - } + // Print the speech that has been recognized so far + if res.isFinal { + result(String(res.bestTranscription.formattedString)) + } + } + } + private func requestTxPermission(result: @escaping FlutterResult) { + let status = SFSpeechRecognizer.authorizationStatus() + switch status { + case .notDetermined: + SFSpeechRecognizer.requestAuthorization({ (status) -> Void in + result(Bool(status == SFSpeechRecognizerAuthorizationStatus.authorized)) + }) + case .denied: + result(Bool(false)) + case .restricted: + result(Bool(false)) + case .authorized: + result(Bool(true)) + default: + result(Bool(true)) + } } - private func requestTxPermission(result: @escaping FlutterResult) { - let status = SFSpeechRecognizer.authorizationStatus() - switch status { - case .notDetermined: - SFSpeechRecognizer.requestAuthorization({(status)->Void in - result(Bool(status == SFSpeechRecognizerAuthorizationStatus.authorized)) - }) - case .denied: - result(Bool(false)) - case .restricted: - result(Bool(false)) - case .authorized: - result(Bool(true)) - default: - result(Bool(true)) - } + private func getLocaleOptions(result: @escaping FlutterResult) { + let locales = SFSpeechRecognizer.supportedLocales() + var localeOptions: [String: String] = [:] + for locale in locales { + localeOptions[locale.identifier] = locale.localizedString(forIdentifier: locale.identifier)! } + result(localeOptions) + } override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - let controller : FlutterViewController = window?.rootViewController as! FlutterViewController - let transcribeChannel = FlutterMethodChannel(name: "voiceoutliner.saga.chat/iostx", binaryMessenger: controller.binaryMessenger) - transcribeChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult)-> Void in - switch call.method { - case "transcribe": - self.receiveTxRequest(call: call, result: result) - case "requestPermission": - self.requestTxPermission(result: result) - default: - result(FlutterMethodNotImplemented) - } - }) - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + let controller: FlutterViewController = window?.rootViewController as! FlutterViewController + let transcribeChannel = FlutterMethodChannel( + name: "voiceoutliner.saga.chat/iostx", binaryMessenger: controller.binaryMessenger) + transcribeChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "transcribe": + self.receiveTxRequest(call: call, result: result) + case "requestPermission": + self.requestTxPermission(result: result) + case "getLocaleOptions": + self.getLocaleOptions(result: result) + default: + result(FlutterMethodNotImplemented) + } + }) + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/lib/consts.dart b/lib/consts.dart index c53596e..7511a04 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -11,6 +11,7 @@ const lastRouteKey = "last_route"; const lastOutlineKey = "last_outline"; const modelDirKey = "model_dir"; const modelLanguageKey = "model_language"; +const localeKey = "ios_locale"; const classicPurple = Color.fromRGBO(169, 129, 234, 1.0); const basePurple = Color.fromRGBO(163, 95, 255, 1); diff --git a/lib/main.dart b/lib/main.dart index 0aa59c3..300d816 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,10 +10,11 @@ import 'package:voice_outliner/repositories/drive_backup.dart'; import 'package:voice_outliner/repositories/vosk_speech_recognizer.dart'; import 'package:voice_outliner/state/outline_state.dart'; import 'package:voice_outliner/state/player_state.dart'; +import 'package:voice_outliner/views/ios_transcription_setup_view.dart'; import 'package:voice_outliner/views/notes_view.dart'; import 'package:voice_outliner/views/onboarding_view.dart'; import 'package:voice_outliner/views/outlines_view.dart'; -import 'package:voice_outliner/views/transcription_setup_view.dart'; +import 'package:voice_outliner/views/vosk_transcription_setup_view.dart'; import 'consts.dart'; @@ -21,7 +22,8 @@ final routes = { "/": const OutlinesView(), "/notes": const NotesView(), "/onboarding": const OnboardingView(), - "/transcription_setup": const TranscriptionSetupView() + "/transcription_setup_vosk": const VoskTranscriptionSetupView(), + "/transcription_setup_ios": const IOSTranscriptionSetupView() }; const generalAppBar = @@ -118,7 +120,14 @@ class _VoiceOutlinerAppState extends State { widget.sharedPreferences.getString(modelDirKey) == null && (widget.sharedPreferences.getBool(shouldTranscribeKey) ?? true) && lastRoute != null) { - return "/transcription_setup"; + return "/transcription_setup_vosk"; + } + // If you've onboarded but don't have language set up on iOS + if (Platform.isIOS && + widget.sharedPreferences.getString(localeKey) == null && + (widget.sharedPreferences.getBool(shouldTranscribeKey) ?? true) && + lastRoute != null) { + return "/transcription_setup_ios"; } if (lastRoute != null) { return lastRoute; diff --git a/lib/repositories/ios_speech_recognizer.dart b/lib/repositories/ios_speech_recognizer.dart index 24dfcb8..14bb20a 100644 --- a/lib/repositories/ios_speech_recognizer.dart +++ b/lib/repositories/ios_speech_recognizer.dart @@ -4,10 +4,10 @@ import 'package:flutter/services.dart'; const iosPlatform = MethodChannel("voiceoutliner.saga.chat/iostx"); -Future recognizeNoteIOS(String path) async { +Future recognizeNoteIOS(String path, String locale) async { try { - final platformRes = - await iosPlatform.invokeMethod("transcribe", {"path": path}); + final platformRes = await iosPlatform + .invokeMethod("transcribe", {"path": path, "locale": locale}); if (platformRes is String) { return platformRes; } else { @@ -32,3 +32,18 @@ Future tryTxPermissionIOS() async { return false; } } + +/// Returns a map {"en-US": "English (US)"} +Future> getLocaleOptions() async { + if (!Platform.isIOS) { + print("Not IOS"); + return {}; + } + try { + final res = await iosPlatform.invokeMethod("getLocaleOptions"); + return Map.from(res); + } catch (err) { + print(err); + return {}; + } +} diff --git a/lib/state/notes_state.dart b/lib/state/notes_state.dart index 1fd2e84..fdf6a04 100644 --- a/lib/state/notes_state.dart +++ b/lib/state/notes_state.dart @@ -29,6 +29,7 @@ class NotesModel extends ChangeNotifier { bool shouldLocate = false; bool showCompleted = true; bool isReady = false; + String locale = "en-US"; Completer _readyCompleter = Completer(); bool isIniting = false; final LinkedList notes = LinkedList(); @@ -78,7 +79,7 @@ class NotesModel extends ChangeNotifier { if (shouldTranscribe && !note.transcribed && note.filePath != null) { final path = _playerModel.getPathFromFilename(note.filePath!); final res = Platform.isIOS - ? await recognizeNoteIOS(path) + ? await recognizeNoteIOS(path, locale) : await voskSpeechRecognize(path); // Guard against writing after user went back if (isReady) { @@ -469,6 +470,9 @@ class NotesModel extends ChangeNotifier { shouldTranscribe = prefs.getBool(shouldTranscribeKey) ?? false; shouldLocate = prefs.getBool(shouldLocateKey) ?? false; showCompleted = prefs.getBool(showCompletedKey) ?? true; + if (Platform.isIOS) { + locale = prefs.getString(localeKey) ?? locale; + } isReady = true; _readyCompleter.complete(true); _readyCompleter = Completer(); diff --git a/lib/views/ios_transcription_setup_view.dart b/lib/views/ios_transcription_setup_view.dart new file mode 100644 index 0000000..39d2191 --- /dev/null +++ b/lib/views/ios_transcription_setup_view.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:voice_outliner/widgets/ios_locale_selector.dart'; + +class IOSTranscriptionSetupView extends StatelessWidget { + const IOSTranscriptionSetupView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Setup"), + automaticallyImplyLeading: false, + ), + body: Padding( + padding: const EdgeInsets.all(20.0), + child: Center( + child: Column(children: [ + const Text( + "Select transcription language", + style: TextStyle(fontSize: 18.0), + ), + const SizedBox(height: 20), + const IOSLocaleSelector(), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => Navigator.pushNamedAndRemoveUntil( + context, "/", (route) => false), + child: const Text("continue")) + ]))), + ); + } +} diff --git a/lib/views/onboarding_view.dart b/lib/views/onboarding_view.dart index f41f1b2..612845c 100644 --- a/lib/views/onboarding_view.dart +++ b/lib/views/onboarding_view.dart @@ -51,7 +51,11 @@ class _OnboardingViewState extends State { void onDone() { Navigator.pushNamedAndRemoveUntil( - context, Platform.isIOS ? "/" : "/transcription_setup", (_) => false); + context, + Platform.isIOS + ? "/transcription_setup_ios" + : "/transcription_setup_vosk", + (_) => false); } @override diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index ad3d342..4e2c840 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -8,7 +8,8 @@ import 'package:voice_outliner/consts.dart'; import 'package:voice_outliner/repositories/ios_speech_recognizer.dart'; import 'package:voice_outliner/state/outline_state.dart'; import 'package:voice_outliner/views/drive_settings_view.dart'; -import 'package:voice_outliner/views/transcription_setup_view.dart'; +import 'package:voice_outliner/views/ios_transcription_setup_view.dart'; +import 'package:voice_outliner/views/vosk_transcription_setup_view.dart'; class SettingsView extends StatefulWidget { const SettingsView({Key? key}) : super(key: key); @@ -28,7 +29,6 @@ class _SettingsViewState extends State { Future init() async { sharedPreferences = await SharedPreferences.getInstance(); - setState(() { isInited = true; }); @@ -114,7 +114,7 @@ class _SettingsViewState extends State { ? Column( children: [ const SizedBox(height: 10.0), - if (Platform.isIOS) + if (Platform.isIOS) ...[ SwitchListTile( secondary: const Icon(Icons.voicemail), title: const Text("Transcribe Recordings"), @@ -136,14 +136,28 @@ class _SettingsViewState extends State { sharedPreferences.setBool(shouldTranscribeKey, v); }); }), + if (sharedPreferences.getBool(shouldTranscribeKey) ?? true) + ListTile( + leading: const Icon(Icons.language), + trailing: const Icon(Icons.arrow_forward_ios), + title: const Text("Transcription Language"), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const IOSTranscriptionSetupView())), + ), + ], if (Platform.isAndroid) ListTile( leading: const Icon(Icons.voicemail), + trailing: const Icon(Icons.arrow_forward_ios), title: const Text("Transcription Setup"), onTap: () => Navigator.push( context, MaterialPageRoute( - builder: (_) => const TranscriptionSetupView())), + builder: (_) => + const VoskTranscriptionSetupView())), ), SwitchListTile( secondary: const Icon(Icons.location_pin), diff --git a/lib/views/transcription_setup_view.dart b/lib/views/vosk_transcription_setup_view.dart similarity index 95% rename from lib/views/transcription_setup_view.dart rename to lib/views/vosk_transcription_setup_view.dart index 2031654..ca457cd 100644 --- a/lib/views/transcription_setup_view.dart +++ b/lib/views/vosk_transcription_setup_view.dart @@ -6,14 +6,16 @@ import 'package:voice_outliner/consts.dart'; import 'package:voice_outliner/repositories/ios_speech_recognizer.dart'; import 'package:voice_outliner/repositories/vosk_speech_recognizer.dart'; -class TranscriptionSetupView extends StatefulWidget { - const TranscriptionSetupView({Key? key}) : super(key: key); +class VoskTranscriptionSetupView extends StatefulWidget { + const VoskTranscriptionSetupView({Key? key}) : super(key: key); @override - _TranscriptionSetupViewState createState() => _TranscriptionSetupViewState(); + _VoskTranscriptionSetupViewState createState() => + _VoskTranscriptionSetupViewState(); } -class _TranscriptionSetupViewState extends State { +class _VoskTranscriptionSetupViewState + extends State { SharedPreferences? sharedPreferences; bool isInited = false; bool loading = false; diff --git a/lib/widgets/ios_locale_selector.dart b/lib/widgets/ios_locale_selector.dart new file mode 100644 index 0000000..b25e030 --- /dev/null +++ b/lib/widgets/ios_locale_selector.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:voice_outliner/consts.dart'; +import 'package:voice_outliner/repositories/ios_speech_recognizer.dart'; + +class IOSLocaleSelector extends StatefulWidget { + const IOSLocaleSelector({Key? key}) : super(key: key); + + @override + _IOSLocaleSelectorState createState() => _IOSLocaleSelectorState(); +} + +class _IOSLocaleSelectorState extends State { + late SharedPreferences sharedPreferences; + bool isInited = false; + Map locales = {}; + @override + void initState() { + super.initState(); + init(); + } + + Future init() async { + sharedPreferences = await SharedPreferences.getInstance(); + locales = await getLocaleOptions(); + setState(() { + isInited = true; + }); + } + + @override + Widget build(BuildContext context) { + if (!isInited) { + return const Text("loading locales..."); + } + final items = locales.entries + .map((entry) => + DropdownMenuItem(child: Text(entry.value), value: entry.key)) + .toList(growable: false); + items.sort((a, b) => a.value!.compareTo(b.value!)); + return DropdownButton( + icon: const Icon(Icons.language), + menuMaxHeight: 300, + isExpanded: true, + items: items, + value: sharedPreferences.getString(localeKey) ?? "en-US", + onChanged: (v) { + setState(() { + sharedPreferences.setString(localeKey, v!); + }); + }); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 733c076..1fc104c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ -name: voice_outliner -description: Structure your memories +name: voiceliner +description: Braindump better. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.22.1+90 +version: 1.22.2+91 environment: sdk: ">=2.12.0 <3.0.0"