From baf654be4053c4ff1011edeec39ef6317626263e Mon Sep 17 00:00:00 2001 From: JHubi1 Date: Thu, 13 Jun 2024 19:17:02 +0200 Subject: [PATCH] Critical chat fix, added voice mode --- android/app/src/main/AndroidManifest.xml | 14 + android/settings.gradle | 2 +- lib/l10n/app_de.arb | 80 +++- lib/l10n/app_en.arb | 70 ++++ lib/main.dart | 353 ++++------------ lib/screen_settings.dart | 82 +++- lib/screen_voice.dart | 389 ++++++++++++++++++ lib/screen_welcome.dart | 45 +- lib/settings/interface.dart | 6 +- lib/settings/voice.dart | 342 +++++++++++++++ lib/worker/haptic.dart | 4 +- lib/worker/sender.dart | 253 ++++++++++++ lib/worker/setter.dart | 1 + pubspec.lock | 104 +++++ pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 17 files changed, 1454 insertions(+), 303 deletions(-) create mode 100644 lib/screen_voice.dart create mode 100644 lib/settings/voice.dart create mode 100644 lib/worker/sender.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a74d935..1069bb9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index 1d6d19b..774d879 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -20,7 +20,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "2.0.0" apply false } include ":app" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 84500ba..8a2e39a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -145,6 +145,11 @@ "description": "Title of the interface settings section", "context": "Visible in the settings view" }, + "settingsTitleVoice": "Voice", + "@settingsTitleVoice": { + "description": "Title of the voice settings section. Do not translate if not required!", + "context": "Visible in the settings view" + }, "settingsTitleExport": "Exportieren", "@settingsTitleExport": { "description": "Title of the export settings section", @@ -160,6 +165,36 @@ "description": "Text displayed when settings are saved automatically", "context": "Visible in the settings view" }, + "settingsExperimentalAlpha": "alpha", + "@settingsExperimentalAlpha": { + "description": "Text displayed when a feature is in alpha", + "context": "Visible in the settings view" + }, + "settingsExperimentalAlphaDescription": "Diese Funktion befindet sich im Alpha-Status und funktioniert möglicherweise nicht wie beabsichtigt oder erwartet.\nKritische Probleme und/oder dauerhafte kritische Schäden am Gerät und/oder den verwendeten Diensten können nicht ausgeschlossen werden.\nBenutzung auf eigene Gefahr. Keine Haftung seitens des App-Autors.", + "@settingsExperimentalAlphaDescription": { + "description": "Description of the alpha feature", + "context": "Visible in the settings view" + }, + "settingsExperimentalAlphaFeature": "Alpha-Funktion, halte, um mehr zu erfahren", + "@settingsExperimentalAlphaFeature": { + "description": "Text displayed when a feature is in alpha", + "context": "Visible in the settings view" + }, + "settingsExperimentalBeta": "beta", + "@settingsExperimentalBeta": { + "description": "Text displayed when a feature is in beta", + "context": "Visible in the settings view" + }, + "settingsExperimentalBetaDescription": "Diese Funktion befindet sich im Beta-Test und funktioniert möglicherweise nicht wie beabsichtigt oder erwartet.\nWeniger schwerwiegende Probleme können auftreten oder auch nicht. Schäden sollten nicht kritisch sein.\nVerwendung auf eigene Gefahr.", + "@settingsExperimentalBetaDescription": { + "description": "Description of the beta feature", + "context": "Visible in the settings view" + }, + "settingsExperimentalBetaFeature": "Beta-Funktion, halte, um mehr zu erfahren", + "@settingsExperimentalBetaFeature": { + "description": "Text displayed when a feature is in beta", + "context": "Visible in the settings view" + }, "settingsHost": "Host", "@settingsHost": { "description": "Text displayed as description for host input", @@ -333,6 +368,41 @@ "description": "Text displayed for cancel button, should be capitalized", "context": "Visible in the settings view" }, + "settingsVoicePermissionLoading": "Lade Sprachberechtigungen ...", + "@settingsVoicePermissionLoading": { + "description": "Text displayed while loading voice permissions", + "context": "Visible in the settings view" + }, + "settingsVoicePermissionNot": "Berechtigungen nicht erteilt", + "@settingsVoicePermissionNot": { + "description": "Text displayed when voice permissions are not granted", + "context": "Visible in the settings view" + }, + "settingsVoiceNotSupported": "Voice-Modus wird nicht unterstützt", + "@settingsVoiceNotSupported": { + "description": "Text displayed when voice mode is not supported", + "context": "Visible in the settings view" + }, + "settingsVoiceEnable": "Voice-Modus aktivieren", + "@settingsVoiceEnable": { + "description": "Text displayed as description for enable voice mode toggle", + "context": "Visible in the settings view" + }, + "settingsVoiceNoLanguage": "Keine Sprache ausgewählt", + "@settingsVoiceNoLanguage": { + "description": "Text displayed when no language is selected", + "context": "Visible in the settings view" + }, + "settingsVoiceLimitLanguage": "Auf gewählte Sprache beschränken", + "@settingsVoiceLimitLanguage": { + "description": "Text displayed as description for limit language toggle", + "context": "Visible in the settings view" + }, + "settingsVoicePunctuation": "KI Satzzeichen aktivieren", + "@settingsVoicePunctuation": { + "description": "Text displayed as description for enable AI punctuation toggle", + "context": "Visible in the settings view" + }, "settingsExportChats": "Chats exportieren", "@settingsExportChats": { "description": "Text displayed as description for export chats button", @@ -378,11 +448,6 @@ "description": "Warning displayed for export and import options", "context": "Visible in the settings view" }, - "settingsCheckForUpdates": "Beim Öffnen nach Updates suchen", - "@settingsCheckForUpdates": { - "description": "Text displayed as description for check for updates toggle", - "context": "Visible in the settings view" - }, "settingsUpdateCheck": "Nach Updates suchen", "@settingsUpdateCheck": { "description": "Text displayed as description for check for updates button", @@ -444,6 +509,11 @@ "description": "Text displayed for cancel button, should be capitalized", "context": "Visible in the settings view" }, + "settingsCheckForUpdates": "Beim Öffnen nach Updates suchen", + "@settingsCheckForUpdates": { + "description": "Text displayed as description for check for updates toggle", + "context": "Visible in the settings view" + }, "settingsGithub": "GitHub", "@settingsGithub": { "description": "Text displayed as description for GitHub button", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b27522..b81b6ea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -145,6 +145,11 @@ "description": "Title of the interface settings section", "context": "Visible in the settings view" }, + "settingsTitleVoice": "Voice", + "@settingsTitleVoice": { + "description": "Title of the voice settings section. Do not translate if not required!", + "context": "Visible in the settings view" + }, "settingsTitleExport": "Export", "@settingsTitleExport": { "description": "Title of the export settings section", @@ -160,6 +165,36 @@ "description": "Text displayed when settings are saved automatically", "context": "Visible in the settings view" }, + "settingsExperimentalAlpha": "alpha", + "@settingsExperimentalAlpha": { + "description": "Text displayed when a feature is in alpha", + "context": "Visible in the settings view" + }, + "settingsExperimentalAlphaDescription": "This feature is in alpha and may not work as intended or expected.\nCritical issues and/or permanent critical damage to device and/or used services cannot be ruled out.\nUse at your own risk. No liability on the part of the app author.", + "@settingsExperimentalAlphaDescription": { + "description": "Description of the alpha feature", + "context": "Visible in the settings view" + }, + "settingsExperimentalAlphaFeature": "Alpha feature, hold to learn more", + "@settingsExperimentalAlphaFeature": { + "description": "Text displayed when a feature is in alpha", + "context": "Visible in the settings view" + }, + "settingsExperimentalBeta": "beta", + "@settingsExperimentalBeta": { + "description": "Text displayed when a feature is in beta", + "context": "Visible in the settings view" + }, + "settingsExperimentalBetaDescription": "This feature is in beta and may not work intended or expected.\nLess severe issues may or may not occur. Damage shouldn't be critical.\nUse at your own risk.", + "@settingsExperimentalBetaDescription": { + "description": "Description of the beta feature", + "context": "Visible in the settings view" + }, + "settingsExperimentalBetaFeature": "Beta feature, hold to learn more", + "@settingsExperimentalBetaFeature": { + "description": "Text displayed when a feature is in beta", + "context": "Visible in the settings view" + }, "settingsHost": "Host", "@settingsHost": { "description": "Text displayed as description for host input", @@ -333,6 +368,41 @@ "description": "Text displayed for cancel button, should be capitalized", "context": "Visible in the settings view" }, + "settingsVoicePermissionLoading": "Loading voice permissions ...", + "@settingsVoicePermissionLoading": { + "description": "Text displayed while loading voice permissions", + "context": "Visible in the settings view" + }, + "settingsVoicePermissionNot": "Permissions not granted", + "@settingsVoicePermissionNot": { + "description": "Text displayed when voice permissions are not granted", + "context": "Visible in the settings view" + }, + "settingsVoiceNotSupported": "Voice mode not supported", + "@settingsVoiceNotSupported": { + "description": "Text displayed when voice mode is not supported", + "context": "Visible in the settings view" + }, + "settingsVoiceEnable": "Enable voice mode", + "@settingsVoiceEnable": { + "description": "Text displayed as description for enable voice mode toggle", + "context": "Visible in the settings view" + }, + "settingsVoiceNoLanguage": "No language selected", + "@settingsVoiceNoLanguage": { + "description": "Text displayed when no language is selected", + "context": "Visible in the settings view" + }, + "settingsVoiceLimitLanguage": "Limit to selected language", + "@settingsVoiceLimitLanguage": { + "description": "Text displayed as description for limit language toggle", + "context": "Visible in the settings view" + }, + "settingsVoicePunctuation": "Enable AI punctuation", + "@settingsVoicePunctuation": { + "description": "Text displayed as description for enable AI punctuation toggle", + "context": "Visible in the settings view" + }, "settingsExportChats": "Export chats", "@settingsExportChats": { "description": "Text displayed as description for export chats button", diff --git a/lib/main.dart b/lib/main.dart index 7905c8c..ebb7f42 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,9 +10,11 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:url_launcher/url_launcher.dart'; import 'screen_settings.dart'; +import 'screen_voice.dart'; import 'screen_welcome.dart'; import 'worker/setter.dart'; import 'worker/haptic.dart'; +import 'worker/sender.dart'; import 'package:shared_preferences/shared_preferences.dart'; // ignore: depend_on_referenced_packages @@ -22,13 +24,14 @@ import 'package:uuid/uuid.dart'; import 'package:image_picker/image_picker.dart'; import 'package:visibility_detector/visibility_detector.dart'; // import 'package:http/http.dart' as http; -import 'package:ollama_dart/ollama_dart.dart' as llama; -import 'package:dartx/dartx.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; // ignore: depend_on_referenced_packages import 'package:markdown/markdown.dart' as md; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:speech_to_text/speech_to_text.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:permission_handler/permission_handler.dart'; // client configuration @@ -66,6 +69,15 @@ final user = types.User(id: const Uuid().v4()); final assistant = types.User(id: const Uuid().v4()); bool settingsOpen = false; +bool logoVisible = true; +bool menuVisible = false; +bool sendable = false; + +SpeechToText speech = SpeechToText(); +FlutterTts voice = FlutterTts(); +bool voiceSupported = false; + +Function? setMainState; void main() { runApp(const App()); @@ -101,6 +113,12 @@ class _AppState extends State { try { await FlutterDisplayMode.setHighRefreshRate(); } catch (_) {} + + if ((await Permission.bluetoothConnect.isGranted) && + (await Permission.microphone.isGranted)) { + voiceSupported = await speech.initialize(); + } + SharedPreferences.setPrefix("ollama."); SharedPreferences tmp = await SharedPreferences.getInstance(); setState(() { @@ -177,11 +195,7 @@ class MainApp extends StatefulWidget { } class _MainAppState extends State { - bool logoVisible = true; - bool menuVisible = false; - int tipId = Random().nextInt(5); - bool sendable = false; List sidebar(BuildContext context, Function setState) { return List.from([ @@ -747,18 +761,14 @@ class _MainAppState extends State { ]), actions: [ TextButton( - onPressed: () { - HapticFeedback - .selectionClick(); + onPressed: () {selectionHaptic(); Navigator.of(context).pop(); }, child: Text(AppLocalizations.of( context)! .deleteDialogCancel)), TextButton( - onPressed: () { - HapticFeedback - .selectionClick(); + onPressed: () {selectionHaptic(); Navigator.of(context).pop(); for (var i = 0; @@ -1083,259 +1093,8 @@ class _MainAppState extends State { child: const ImageIcon( AssetImage("assets/logo512.png"), size: 44)))), - onSendPressed: (p0) async { - selectionHaptic(); - setState(() { - sendable = false; - }); - - if (host == null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(context)!.noHostSelected), - showCloseIcon: true)); - return; - } - - if (!chatAllowed || model == null) { - if (model == null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)! - .noModelSelected), - showCloseIcon: true)); - } - return; - } - - bool newChat = false; - if (chatUuid == null) { - newChat = true; - chatUuid = const Uuid().v4(); - prefs!.setStringList( - "chats", - (prefs!.getStringList("chats") ?? []).append([ - jsonEncode({ - "title": AppLocalizations.of(context)! - .newChatTitle, - "uuid": chatUuid, - "messages": [] - }) - ]).toList()); - } - - var system = prefs?.getString("system") ?? - "You are a helpful assistant"; - if (prefs!.getBool("noMarkdown") ?? false) { - system += - " You must not use markdown or any other formatting language in any way!"; - } - - List history = [ - llama.Message( - role: llama.MessageRole.system, content: system) - ]; - List images = []; - for (var i = 0; i < messages.length; i++) { - if (jsonDecode(jsonEncode(messages[i]))["text"] != - null) { - history.add(llama.Message( - role: (messages[i].author.id == user.id) - ? llama.MessageRole.user - : llama.MessageRole.system, - content: - jsonDecode(jsonEncode(messages[i]))["text"], - images: (images.isNotEmpty) ? images : null)); - } else { - var uri = jsonDecode(jsonEncode(messages[i]))["uri"] - as String; - String content = (uri - .startsWith("data:image/png;base64,")) - ? uri.removePrefix("data:image/png;base64,") - : base64.encode(await File(uri).readAsBytes()); - uri = uri.removePrefix("data:image/png;base64,"); - images.add(content); - } - } - - history.add(llama.Message( - role: llama.MessageRole.user, - content: p0.text.trim(), - images: (images.isNotEmpty) ? images : null)); - messages.insert( - 0, - types.TextMessage( - author: user, - id: const Uuid().v4(), - text: p0.text.trim())); - - saveChat(chatUuid!, setState); - - setState(() {}); - chatAllowed = false; - - String newId = const Uuid().v4(); - llama.OllamaClient client = llama.OllamaClient( - headers: (jsonDecode( - prefs!.getString("hostHeaders") ?? "{}") - as Map) - .cast(), - baseUrl: "$host/api"); - - try { - if ((prefs!.getString("requestType") ?? "stream") == - "stream") { - final stream = client - .generateChatCompletionStream( - request: llama.GenerateChatCompletionRequest( - model: model!, - messages: history, - keepAlive: int.parse( - prefs!.getString("keepAlive") ?? - "300")), - ) - .timeout(const Duration(seconds: 30)); - - String text = ""; - await for (final res in stream) { - text += (res.message?.content ?? ""); - for (var i = 0; i < messages.length; i++) { - if (messages[i].id == newId) { - messages.removeAt(i); - break; - } - } - if (chatAllowed) return; - if (text.trim() == "") { - throw Exception(); - } - messages.insert( - 0, - types.TextMessage( - author: assistant, - id: newId, - text: text)); - setState(() {}); - lightHaptic(); - } - } else { - llama.GenerateChatCompletionResponse request; - request = await client - .generateChatCompletion( - request: llama.GenerateChatCompletionRequest( - model: model!, - messages: history, - keepAlive: int.parse( - prefs!.getString("keepAlive") ?? - "300")), - ) - .timeout(const Duration(seconds: 30)); - if (chatAllowed) return; - if (request.message!.content.trim() == "") { - throw Exception(); - } - messages.insert( - 0, - types.TextMessage( - author: assistant, - id: newId, - text: request.message!.content)); - setState(() {}); - lightHaptic(); - } - } catch (e) { - for (var i = 0; i < messages.length; i++) { - if (messages[i].id == newId) { - messages.removeAt(i); - break; - } - } - setState(() { - chatAllowed = true; - messages.removeAt(0); - if (messages.isEmpty) { - var tmp = (prefs!.getStringList("chats") ?? []); - chatUuid = null; - for (var i = 0; i < tmp.length; i++) { - if (jsonDecode((prefs!.getStringList("chats") ?? - [])[i])["uuid"] == - chatUuid) { - tmp.removeAt(i); - prefs!.setStringList("chats", tmp); - break; - } - } - } - }); - // ignore: use_build_context_synchronously - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - // ignore: use_build_context_synchronously - content: Text(AppLocalizations.of(context)! - .settingsHostInvalid("timeout")), - showCloseIcon: true)); - return; - } - - saveChat(chatUuid!, setState); - - if (newChat && - (prefs!.getBool("generateTitles") ?? true)) { - void setTitle() async { - List> history = []; - for (var i = 0; i < messages.length; i++) { - if (jsonDecode(jsonEncode(messages[i]))["text"] == - null) { - continue; - } - history.add({ - "role": (messages[i].author == user) - ? "user" - : "assistant", - "content": - jsonDecode(jsonEncode(messages[i]))["text"] - }); - } - history = history.reversed.toList(); - - try { - final generated = await client - .generateCompletion( - request: llama.GenerateCompletionRequest( - model: model!, - prompt: - "You must not use markdown or any other formatting language! Create a short title for the subject of the conversation described in the following json object. It is not allowed to be too general; no 'Assistance', 'Help' or similar!\n\n```json\n${jsonEncode(history)}\n```", - keepAlive: int.parse( - prefs!.getString("keepAlive") ?? - "300")), - ) - .timeout(const Duration(seconds: 10)); - var title = generated.response! - .replaceAll("\"", "") - .replaceAll("'", "") - .replaceAll("*", "") - .replaceAll("_", "") - .trim(); - var tmp = (prefs!.getStringList("chats") ?? []); - for (var i = 0; i < tmp.length; i++) { - if (jsonDecode((prefs!.getStringList("chats") ?? - [])[i])["uuid"] == - chatUuid) { - var tmp2 = jsonDecode(tmp[i]); - tmp2["title"] = title; - tmp[i] = jsonEncode(tmp2); - break; - } - } - prefs!.setStringList("chats", tmp); - } catch (_) {} - - setState(() {}); - } - - setTitle(); - } - - setState(() {}); - chatAllowed = true; + onSendPressed: (p0) { + send(p0.text, context, setState); }, onMessageDoubleTap: (context, p1) { selectionHaptic(); @@ -1408,7 +1167,20 @@ class _MainAppState extends State { setState(() {}); }, onAttachmentPressed: (!multimodal) - ? null + ? (prefs?.getBool("voiceModeEnabled") ?? false) + ? (model != null) + ? () { + selectionHaptic(); + setMainState = setState; + settingsOpen = true; + logoVisible = false; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const ScreenVoice())); + } + : null + : null : () { selectionHaptic(); if (!chatAllowed || model == null) return; @@ -1455,9 +1227,28 @@ class _MainAppState extends State { SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () async { - HapticFeedback - .selectionClick(); + onPressed: () async {selectionHaptic(); + Navigator.of(context) + .pop(); + setMainState = setState; + settingsOpen = true; + logoVisible = false; + Navigator.of(context).push( + MaterialPageRoute( + builder: + (context) => + const ScreenVoice())); + }, + icon: const Icon(Icons + .headphones_rounded), + label: Text(AppLocalizations + .of(context)! + .settingsTitleVoice))), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async {selectionHaptic(); Navigator.of(context) .pop(); @@ -1497,8 +1288,7 @@ class _MainAppState extends State { messages.insert( 0, message); setState(() {}); - HapticFeedback - .selectionClick(); + selectionHaptic(); }, icon: const Icon(Icons .photo_camera_rounded), @@ -1510,9 +1300,7 @@ class _MainAppState extends State { SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () async { - HapticFeedback - .selectionClick(); + onPressed: () async {selectionHaptic(); Navigator.of(context) .pop(); @@ -1552,8 +1340,7 @@ class _MainAppState extends State { messages.insert( 0, message); setState(() {}); - HapticFeedback - .selectionClick(); + selectionHaptic(); }, icon: const Icon( Icons.image_rounded), @@ -1589,8 +1376,12 @@ class _MainAppState extends State { (theme ?? ThemeData()).colorScheme.surface, primaryColor: (theme ?? ThemeData()).colorScheme.primary, - attachmentButtonIcon: - const Icon(Icons.add_a_photo_rounded), + attachmentButtonIcon: !multimodal + ? (prefs?.getBool("voiceModeEnabled") ?? + false) + ? const Icon(Icons.headphones_rounded) + : null + : const Icon(Icons.add_a_photo_rounded), sendButtonIcon: SizedBox( height: 24, child: CircleAvatar( @@ -1635,7 +1426,11 @@ class _MainAppState extends State { backgroundColor: (themeDark ?? ThemeData.dark()).colorScheme.surface, primaryColor: (themeDark ?? ThemeData.dark()).colorScheme.primary.withAlpha(40), secondaryColor: (themeDark ?? ThemeData.dark()).colorScheme.primary.withAlpha(20), - attachmentButtonIcon: const Icon(Icons.add_a_photo_rounded), + attachmentButtonIcon: !multimodal + ? (prefs?.getBool("voiceModeEnabled") ?? false) + ? const Icon(Icons.headphones_rounded) + : null + : const Icon(Icons.add_a_photo_rounded), sendButtonIcon: SizedBox( height: 24, child: CircleAvatar( diff --git a/lib/screen_settings.dart b/lib/screen_settings.dart index d936f8d..abe1483 100644 --- a/lib/screen_settings.dart +++ b/lib/screen_settings.dart @@ -10,6 +10,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'settings/behavior.dart'; import 'settings/interface.dart'; +import 'settings/voice.dart'; import 'settings/export.dart'; import 'settings/about.dart'; @@ -18,7 +19,11 @@ import 'package:http/http.dart' as http; import 'package:bitsdojo_window/bitsdojo_window.dart'; Widget toggle(BuildContext context, String text, bool value, - Function(bool value) onChanged) { + Function(bool value) onChanged, + {bool disabled = false, + void Function()? onDisabledTap, + void Function()? onLongTap, + void Function()? onDoubleTap}) { var space = "⁣"; // Invisible character: U+2063 var spacePlus = " $space"; return InkWell( @@ -26,8 +31,17 @@ Widget toggle(BuildContext context, String text, bool value, highlightColor: Colors.transparent, hoverColor: Colors.transparent, onTap: () { - onChanged(!value); + if (disabled) { + selectionHaptic(); + if (onDisabledTap != null) { + onDisabledTap(); + } + } else { + onChanged(!value); + } }, + onLongPress: onLongTap, + onDoubleTap: onDoubleTap, child: Padding( padding: const EdgeInsets.only(top: 4, bottom: 4), child: Stack(children: [ @@ -43,6 +57,7 @@ Widget toggle(BuildContext context, String text, bool value, overflow: TextOverflow.ellipsis, maxLines: 1, style: TextStyle( + color: disabled ? Colors.grey : null, backgroundColor: (Theme.of(context).brightness == Brightness.light) ? (theme ?? ThemeData()).colorScheme.surface @@ -56,7 +71,22 @@ Widget toggle(BuildContext context, String text, bool value, : (themeDark ?? ThemeData.dark()).colorScheme.surface, child: SizedBox( height: 40, - child: Switch(value: value, onChanged: onChanged))) + child: Switch( + value: value, + onChanged: disabled + ? (p0) { + selectionHaptic(); + if (onDisabledTap != null) { + onDisabledTap(); + } + } + : onChanged, + trackOutlineColor: disabled + ? const WidgetStatePropertyAll(Colors.grey) + : null, + thumbColor: disabled + ? const WidgetStatePropertyAll(Colors.grey) + : null))) ]), ]), ), @@ -86,17 +116,36 @@ Widget titleDivider({double top = 16, double bottom = 16}) { )); } -Widget button(String text, IconData icon, void Function()? onPressed, - {Color? color}) { +Widget button(String text, IconData? icon, void Function()? onPressed, + {Color? color, + bool disabled = false, + void Function()? onDisabledTap, + void Function()? onLongTap, + void Function()? onDoubleTap}) { return InkWell( - onTap: onPressed, + onTap: disabled + ? () { + selectionHaptic(); + if (onDisabledTap != null) { + onDisabledTap(); + } + } + : onPressed, + onLongPress: onLongTap, + onDoubleTap: onDoubleTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), child: Row(children: [ - Icon(icon, color: color), - const SizedBox(width: 16, height: 42), - Expanded(child: Text(text, style: TextStyle(color: color))) + (icon != null) + ? Icon(icon, color: disabled ? Colors.grey : color) + : const SizedBox.shrink(), + (icon != null) + ? const SizedBox(width: 16, height: 42) + : const SizedBox.shrink(), + Expanded( + child: Text(text, + style: TextStyle(color: disabled ? Colors.grey : color))) ]), )); } @@ -389,6 +438,21 @@ class _ScreenSettingsState extends State { builder: (context) => const ScreenSettingsInterface())); }), + (!(Platform.isWindows || + Platform.isLinux || + Platform.isMacOS)) + ? button( + AppLocalizations.of(context)! + .settingsTitleVoice, + Icons.headphones_rounded, () { + selectionHaptic(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const ScreenSettingsVoice())); + }) + : const SizedBox.shrink(), button( AppLocalizations.of(context)!.settingsTitleExport, Icons.share_rounded, () { diff --git a/lib/screen_voice.dart b/lib/screen_voice.dart new file mode 100644 index 0000000..4a16c85 --- /dev/null +++ b/lib/screen_voice.dart @@ -0,0 +1,389 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ollama_app/worker/haptic.dart'; +import 'package:ollama_app/worker/setter.dart'; + +import 'package:speech_to_text/speech_to_text.dart' as stt; +import 'package:ollama_dart/ollama_dart.dart' as llama; +import 'package:datetime_loop/datetime_loop.dart'; + +import 'main.dart'; +import 'worker/sender.dart'; +import 'settings/voice.dart'; + +class ScreenVoice extends StatefulWidget { + const ScreenVoice({super.key}); + + @override + State createState() => _ScreenVoiceState(); +} + +class _ScreenVoiceState extends State { + Iterable languageOptionIds = []; + Iterable languageOptions = []; + + bool speaking = false; + bool aiThinking = false; + + bool sttDone = true; + String text = ""; + String aiText = ""; + + bool intendedStop = false; + + void setBrightness() { + WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = + () { + // invert colors used, because brightness not updated yet + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + (prefs!.getString("brightness") ?? "system") == "system" + ? ((MediaQuery.of(context).platformBrightness == + Brightness.light) + ? (themeDark ?? ThemeData.dark()).colorScheme.surface + : (theme ?? ThemeData()).colorScheme.surface) + : (prefs!.getString("brightness") == "dark" + ? (themeDark ?? ThemeData()).colorScheme.surface + : (theme ?? ThemeData.dark()).colorScheme.surface), + systemNavigationBarIconBrightness: + (((prefs!.getString("brightness") ?? "system") == "system" && + MediaQuery.of(context).platformBrightness == + Brightness.dark) || + prefs!.getString("brightness") == "light") + ? Brightness.dark + : Brightness.light)); + }; + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + (prefs!.getString("brightness") ?? "system") == "system" + ? ((MediaQuery.of(context).platformBrightness == + Brightness.light) + ? (theme ?? ThemeData.dark()).colorScheme.surface + : (themeDark ?? ThemeData()).colorScheme.surface) + : (prefs!.getString("brightness") == "dark" + ? (themeDark ?? ThemeData()).colorScheme.surface + : (theme ?? ThemeData.dark()).colorScheme.surface), + systemNavigationBarIconBrightness: + (((prefs!.getString("brightness") ?? "system") == "system" && + MediaQuery.of(context).platformBrightness == + Brightness.light) || + prefs!.getString("brightness") == "light") + ? Brightness.dark + : Brightness.light)); + } + + void process() async { + setState(() { + speaking = true; + sttDone = false; + }); + var textOldOld = text; + var textOld = ""; + text = ""; + + speech.listen( + localeId: (prefs!.getString("voiceLanguage") ?? ""), + listenOptions: + stt.SpeechListenOptions(listenMode: stt.ListenMode.dictation), + onResult: (result) { + lightHaptic(); + if (!speaking) return; + setState(() { + sttDone = result.finalResult; + text = result.recognizedWords; + }); + }, + pauseFor: const Duration(seconds: 3)); + + DateTime start = DateTime.now(); + bool timeout = false; + await Future.doWhile(() => + Future.delayed(const Duration(milliseconds: 1)).then((_) { + if (textOld != text) { + start = DateTime.now(); + } + timeout = + (DateTime.now().difference(start) >= const Duration(seconds: 3)); + textOld = text; + return !sttDone && speaking && !timeout; + })); + if (!sttDone || timeout) { + sttDone = true; + speech.stop(); + if (timeout) { + text = textOldOld; + try { + setState(() {}); + } catch (_) {} + } + if (!intendedStop) { + speaking = false; + try { + setState(() {}); + } catch (_) {} + return; + } else { + intendedStop = false; + try { + setState(() {}); + } catch (_) {} + } + } + + if (text.isEmpty) { + setState(() { + speaking = false; + }); + return; + } + + aiText = ""; + heavyHaptic(); + + aiThinking = true; + try { + if (prefs!.getBool("aiPunctuation") ?? true) { + final generated = await llama.OllamaClient( + headers: (jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map) + .cast(), + baseUrl: "$host/api", + ) + .generateCompletion( + request: llama.GenerateCompletionRequest( + model: model!, + prompt: + "Add punctuation and syntax to the following sentence. You must not change order of words or a word in itself! You must not add any word or phrase or remove one! Do not change between formal and personal form, keep the original one!\n\n$text", + keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")), + ) + .timeout(const Duration(seconds: 10)); + setState(() { + text = generated.response!; + }); + } + } catch (_) {} + + // ignore: use_build_context_synchronously + send(text, context, setState, onStream: (currentText, done) async { + setState(() { + aiText = currentText; + lightHaptic(); + }); + + if (done && + (await voice.getLanguages as List).contains( + (prefs!.getString("voiceLanguage") ?? "en_US") + .replaceAll("_", "-"))) { + aiThinking = false; + heavyHaptic(); + voice.setLanguage((prefs!.getString("voiceLanguage") ?? "en_US") + .replaceAll("_", "-")); + voice.setSpeechRate(0.6); + voice.setCompletionHandler(() async { + speaking = false; + try { + setState(() {}); + } catch (_) {} + process(); + }); + var tmp = aiText; + tmp.replaceAll("-", "."); + tmp.replaceAll("*", "."); + voice.speak(tmp); + } + }, + addToSystem: (prefs!.getBool("voiceLimitLanguage") ?? true) + ? "You must write in the following language: ${prefs!.getString("voiceLanguage") ?? "en_US"}!" + : null); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = + () { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + (themeDark ?? ThemeData.dark()).colorScheme.surface, + systemNavigationBarIconBrightness: Brightness.dark)); + }; + + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + (themeDark ?? ThemeData.dark()).colorScheme.surface, + systemNavigationBarIconBrightness: Brightness.dark)); + setState(() {}); + }); + + void load() async { + var tmp = await speech.locales(); + languageOptionIds = tmp.map((e) => e.localeId); + languageOptions = tmp.map((e) => e.name); + setState(() {}); + } + + load(); + + void loadProcess() async { + await Future.delayed(const Duration(milliseconds: 500)); + process(); + } + + loadProcess(); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: themeDark!, + child: PopScope( + canPop: !aiThinking, + onPopInvoked: (didPop) { + speaking = false; + voice.stop(); + if (chatUuid != null) { + loadChat(chatUuid!, setMainState!); + } + settingsOpen = false; + logoVisible = true; + setBrightness(); + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + speaking = false; + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.close_rounded, + color: Colors.grey)), + title: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text(model!.split(":")[0], + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + style: const TextStyle( + fontFamily: "monospace", fontSize: 16))), + ], + ), + actions: [ + IconButton( + onPressed: () { + speaking = false; + settingsOpen = false; + logoVisible = true; + setBrightness(); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => + const ScreenSettingsVoice())); + }, + icon: const Icon( + Icons.settings_rounded, + color: Colors.grey, + )) + ]), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Column(mainAxisSize: MainAxisSize.max, children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Center( + child: Text(text, + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + style: const TextStyle( + color: Colors.grey, + fontFamily: "monospace"))), + )) + ]), + ), + Expanded( + child: Center( + child: DateTimeLoopBuilder( + timeUnit: TimeUnit.seconds, + builder: (context, dateTime, child) { + return SizedBox( + height: 96, + width: 96, + child: AnimatedScale( + scale: speaking + ? aiThinking + ? (dateTime.second).isEven + ? 2.4 + : 2 + : 2 + : dateTime.second + .toString() + .endsWith("1") + ? 1.6 + : 1.4, + duration: aiThinking + ? const Duration(seconds: 1) + : const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: InkWell( + borderRadius: + BorderRadius.circular(48), + onTap: () { + if (speaking && !aiThinking) { + intendedStop = true; + speaking = false; + voice.stop(); + return; + } + process(); + }, + child: CircleAvatar( + backgroundColor: themeDark! + .colorScheme.primary + .withAlpha( + !speaking ? 200 : 255), + child: AnimatedSwitcher( + duration: const Duration( + milliseconds: 200), + child: speaking + ? aiThinking + ? Icon(Icons.auto_awesome_rounded, + color: themeDark! + .colorScheme + .secondary, + key: const ValueKey( + "aiThinking")) + : sttDone + ? Icon(Icons.volume_up_rounded, + color: themeDark! + .colorScheme + .secondary, + key: const ValueKey( + "tts")) + : Icon(Icons.mic_rounded, color: themeDark!.colorScheme.secondary, key: const ValueKey("stt")) + : null)))), + ); + }))), + Expanded( + child: Column(mainAxisSize: MainAxisSize.max, children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Center( + child: Text(aiText, + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + style: const TextStyle( + fontFamily: "monospace"))), + )) + ]), + ) + ], + )))); + } +} diff --git a/lib/screen_welcome.dart b/lib/screen_welcome.dart index c267f19..e4b2f0f 100644 --- a/lib/screen_welcome.dart +++ b/lib/screen_welcome.dart @@ -106,13 +106,50 @@ class _ScreenWelcomeState extends State { curve: Curves.easeInOut); } else { prefs!.setBool("welcomeFinished", true); + + WidgetsBinding.instance.platformDispatcher + .onPlatformBrightnessChanged = () { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: + (prefs!.getString("brightness") ?? "system") == "system" + ? ((MediaQuery.of(context).platformBrightness == + Brightness.light) + ? (themeDark ?? ThemeData.dark()) + .colorScheme + .surface + : (theme ?? ThemeData()).colorScheme.surface) + : (prefs!.getString("brightness") == "dark" + ? (themeDark ?? ThemeData()).colorScheme.surface + : (theme ?? ThemeData.dark()) + .colorScheme + .surface), + systemNavigationBarIconBrightness: + (((prefs!.getString("brightness") ?? "system") == + "system" && + MediaQuery.of(context).platformBrightness == + Brightness.dark) || + prefs!.getString("brightness") == "light") + ? Brightness.dark + : Brightness.light)); + }; SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( systemNavigationBarColor: - (Theme.of(context).brightness == Brightness.light) - ? (theme ?? ThemeData()).colorScheme.surface - : (themeDark ?? ThemeData.dark()).colorScheme.surface, + (prefs!.getString("brightness") ?? "system") == "system" + ? ((MediaQuery.of(context).platformBrightness == + Brightness.light) + ? (theme ?? ThemeData.dark()).colorScheme.surface + : (themeDark ?? ThemeData()).colorScheme.surface) + : (prefs!.getString("brightness") == "dark" + ? (themeDark ?? ThemeData()).colorScheme.surface + : (theme ?? ThemeData.dark()) + .colorScheme + .surface), systemNavigationBarIconBrightness: - (Theme.of(context).brightness == Brightness.light) + (((prefs!.getString("brightness") ?? "system") == + "system" && + MediaQuery.of(context).platformBrightness == + Brightness.light) || + prefs!.getString("brightness") == "light") ? Brightness.dark : Brightness.light)); Navigator.pushReplacement(context, diff --git a/lib/settings/interface.dart b/lib/settings/interface.dart index fbe2e0c..899e4fd 100644 --- a/lib/settings/interface.dart +++ b/lib/settings/interface.dart @@ -340,8 +340,7 @@ class _ScreenSettingsInterfaceState extends State { actions: [ TextButton( onPressed: () { - HapticFeedback - .selectionClick(); + selectionHaptic(); Navigator.of(context).pop(); }, child: Text(AppLocalizations.of( @@ -349,8 +348,7 @@ class _ScreenSettingsInterfaceState extends State { .settingsBrightnessRestartCancel)), TextButton( onPressed: () async { - HapticFeedback - .selectionClick(); + selectionHaptic(); await prefs!.setString( "brightness", p0.elementAt(0)); diff --git a/lib/settings/voice.dart b/lib/settings/voice.dart new file mode 100644 index 0000000..10206ea --- /dev/null +++ b/lib/settings/voice.dart @@ -0,0 +1,342 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:ollama_app/worker/haptic.dart'; + +import '../main.dart'; +// import '../worker/haptic.dart'; +import '../screen_settings.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class ScreenSettingsVoice extends StatefulWidget { + const ScreenSettingsVoice({super.key}); + + @override + State createState() => _ScreenSettingsVoiceState(); +} + +class _ScreenSettingsVoiceState extends State { + bool permissionLoading = true; + bool permissionRecord = false; + bool permissionBluetooth = false; + + Iterable languageOptionIds = []; + Iterable languageOptions = []; + + @override + void initState() { + super.initState(); + void load() async { + var tmp = await speech.locales(); + languageOptionIds = tmp.map((e) => e.localeId); + languageOptions = tmp.map((e) => e.name); + + permissionRecord = await Permission.microphone.isGranted; + permissionBluetooth = await Permission.bluetoothConnect.isGranted; + permissionLoading = false; + setState(() {}); + } + + load(); + } + + @override + Widget build(BuildContext context) { + return WindowBorder( + color: Theme.of(context).colorScheme.surface, + child: Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.settingsTitleVoice)), + body: Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Column(children: [ + Expanded( + child: ListView(children: [ + // const SizedBox(height: 16), + ((prefs!.getBool("voiceModeEnabled") ?? false) || + permissionLoading || + (permissionBluetooth && + permissionRecord && + voiceSupported)) + ? const SizedBox.shrink() + : button( + permissionLoading + ? AppLocalizations.of(context)! + .settingsVoicePermissionLoading + : (permissionBluetooth && permissionRecord) + ? AppLocalizations.of(context)! + .settingsVoiceNotSupported + : AppLocalizations.of(context)! + .settingsVoicePermissionNot, + Icons.info_rounded, () { + if (permissionLoading) return; + if (!(permissionBluetooth && permissionRecord)) { + void load() async { + try { + if (await Permission + .bluetooth.isPermanentlyDenied || + await Permission + .microphone.isPermanentlyDenied) { + await openAppSettings(); + } + permissionRecord = await Permission.microphone + .request() + .isGranted; + permissionBluetooth = await Permission + .bluetoothConnect + .request() + .isGranted; + permissionLoading = false; + + if (permissionBluetooth && permissionRecord) { + voiceSupported = await speech.initialize(); + } + + setState(() {}); + } catch (_) { + permissionLoading = false; + try { + setState(() {}); + } catch (_) {} + } + } + + load(); + } + }), + toggle( + context, + AppLocalizations.of(context)!.settingsVoiceEnable, + (prefs!.getBool("voiceModeEnabled") ?? false), (value) { + selectionHaptic(); + prefs!.setBool("voiceModeEnabled", value); + setState(() {}); + }, disabled: !voiceSupported), + button( + ((prefs!.getString("voiceLanguage") ?? "") == "" || + languageOptions.isEmpty) + ? AppLocalizations.of(context)! + .settingsVoiceNoLanguage + : () { + for (int i = 0; + i < languageOptionIds.length; + i++) { + if (languageOptionIds.elementAt(i) == + prefs!.getString("voiceLanguage")) { + return languageOptions.elementAt(i); + } + } + return ""; + }(), + Icons.language_rounded, () { + int usedIndex = -1; + Function? setModalState; + void load() async { + var tmp = await speech.locales(); + languageOptionIds = tmp.map((e) => e.localeId); + languageOptions = tmp.map((e) => e.name); + + if ((prefs!.getString("voiceLanguage") ?? "") != "") { + for (int i = 0; i < languageOptionIds.length; i++) { + if (languageOptionIds.elementAt(i) == + (prefs!.getString("voiceLanguage") ?? "")) { + usedIndex = i; + setModalState!(() {}); + break; + } + } + } + } + + selectionHaptic(); + + load(); + + showModalBottomSheet( + context: context, + builder: + (context) => StatefulBuilder( + builder: (context, setLocalState) { + setModalState = setLocalState; + return PopScope( + onPopInvoked: (didPop) { + if (usedIndex == -1) return; + prefs!.setString( + "voiceLanguage", + languageOptionIds + .elementAt(usedIndex)); + setState(() {}); + }, + child: Container( + width: ((Platform.isWindows || + Platform.isLinux || + Platform.isMacOS) && + MediaQuery.of(context) + .size + .width >= + 1000) + ? null + : double.infinity, + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: (Platform.isWindows || + Platform.isLinux || + Platform.isMacOS) + ? 16 + : 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: ((Platform + .isWindows || + Platform + .isLinux || + Platform + .isMacOS) && + MediaQuery.of( + context) + .size + .width >= + 1000) + ? 300 + : double.infinity, + constraints: BoxConstraints( + maxHeight: + MediaQuery.of( + context) + .size + .height * + 0.4), + child: + SingleChildScrollView( + scrollDirection: + Axis.vertical, + child: Wrap( + spacing: ((Platform.isWindows || + Platform + .isLinux || + Platform + .isMacOS) && + MediaQuery.of(context).size.width >= + 1000) + ? 10.0 + : 5.0, + runSpacing: (Platform.isWindows || + Platform + .isLinux || + Platform + .isMacOS) + ? (MediaQuery.of(context).size.width >= + 1000) + ? 10.0 + : 5.0 + : 0.0, + alignment: + WrapAlignment + .center, + children: List< + Widget>.generate( + languageOptionIds + .length, + (int index) { + return ChoiceChip( + label: Text( + languageOptions + .elementAt(index)), + selected: + usedIndex == + index, + avatar: (usedIndex == + index) + ? null + : (languageOptionIds.elementAt(index).startsWith(AppLocalizations.of(context)!.localeName)) + ? const Icon(Icons.star_rounded) + : null, + checkmarkColor: (usedIndex == + index) + ? ((MediaQuery.of(context).platformBrightness == Brightness.light) + ? (theme ?? ThemeData()).colorScheme.secondary + : (themeDark ?? ThemeData.dark()).colorScheme.secondary) + : null, + labelStyle: (usedIndex == + index) + ? TextStyle( + color: (MediaQuery.of(context).platformBrightness == Brightness.light) ? (theme ?? ThemeData()).colorScheme.secondary : (themeDark ?? ThemeData.dark()).colorScheme.secondary) + : null, + selectedColor: (MediaQuery.of(context).platformBrightness == + Brightness + .light) + ? (theme ?? ThemeData()) + .colorScheme + .primary + : (themeDark ?? ThemeData.dark()) + .colorScheme + .primary, + onSelected: + (bool + selected) { + selectionHaptic(); + setLocalState( + () { + usedIndex = selected + ? index + : -1; + }); + }, + ); + }, + ).toList(), + ))) + ]))); + })); + }, + disabled: (!voiceSupported || + !(prefs!.getBool("voiceModeEnabled") ?? false))), + titleDivider(), + toggle( + context, + AppLocalizations.of(context)! + .settingsVoiceLimitLanguage, + (prefs!.getBool("voiceLimitLanguage") ?? true), + (value) { + selectionHaptic(); + prefs!.setBool("voiceLimitLanguage", value); + setState(() {}); + }, + disabled: (!voiceSupported || + !(prefs!.getBool("voiceModeEnabled") ?? false))), + toggle( + context, + AppLocalizations.of(context)!.settingsVoicePunctuation, + (prefs!.getBool("aiPunctuation") ?? true), (value) { + selectionHaptic(); + prefs!.setBool("aiPunctuation", value); + setState(() {}); + }, + disabled: (!voiceSupported || + !(prefs!.getBool("voiceModeEnabled") ?? false))) + ]), + ), + const SizedBox(height: 16), + button( + AppLocalizations.of(context)! + .settingsExperimentalBetaFeature, + Icons.warning_rounded, + null, + color: Colors.orange, onLongTap: () { + selectionHaptic(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .settingsExperimentalBetaDescription), + showCloseIcon: true)); + }) + ]))), + ); + } +} diff --git a/lib/worker/haptic.dart b/lib/worker/haptic.dart index 37ffb52..d73210c 100644 --- a/lib/worker/haptic.dart +++ b/lib/worker/haptic.dart @@ -18,5 +18,7 @@ void heavyHaptic() { void selectionHaptic() { if (!(prefs!.getBool("enableHaptic") ?? true)) return; - HapticFeedback.selectionClick(); + // same name but for better experience, change behavior + HapticFeedback.lightImpact(); + // HapticFeedback.selectionClick(); } diff --git a/lib/worker/sender.dart b/lib/worker/sender.dart new file mode 100644 index 0000000..68178ad --- /dev/null +++ b/lib/worker/sender.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'haptic.dart'; +import 'setter.dart'; +import '../main.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:ollama_dart/ollama_dart.dart' as llama; +import 'package:dartx/dartx.dart'; +import 'package:uuid/uuid.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_chat_types/flutter_chat_types.dart' as types; + +List images = []; +Future> getHistory([String? addToSystem]) async { + var system = prefs?.getString("system") ?? "You are a helpful assistant"; + if (prefs!.getBool("noMarkdown") ?? false) { + system += + "\nYou must not use markdown or any other formatting language in any way!"; + } + if (addToSystem != null) { + system += "\n$addToSystem"; + } + + List history = [ + llama.Message(role: llama.MessageRole.system, content: system) + ]; + List history2 = []; + images = []; + for (var i = 0; i < messages.length; i++) { + if (jsonDecode(jsonEncode(messages[i]))["text"] != null) { + history2.add(llama.Message( + role: (messages[i].author.id == user.id) + ? llama.MessageRole.user + : llama.MessageRole.system, + content: jsonDecode(jsonEncode(messages[i]))["text"], + images: (images.isNotEmpty) ? images : null)); + images = []; + } else { + var uri = jsonDecode(jsonEncode(messages[i]))["uri"] as String; + String content = (uri.startsWith("data:image/png;base64,")) + ? uri.removePrefix("data:image/png;base64,") + : base64.encode(await File(uri).readAsBytes()); + uri = uri.removePrefix("data:image/png;base64,"); + images.add(content); + } + } + + history.addAll(history2.reversed.toList()); + return history; +} + +Future send(String value, BuildContext context, Function setState, + {void Function(String currentText, bool done)? onStream, + String? addToSystem}) async { + selectionHaptic(); + setState(() { + sendable = false; + }); + + if (host == null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.noHostSelected), + showCloseIcon: true)); + return ""; + } + + if (!chatAllowed || model == null) { + if (model == null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.noModelSelected), + showCloseIcon: true)); + } + return ""; + } + + bool newChat = false; + if (chatUuid == null) { + newChat = true; + chatUuid = const Uuid().v4(); + prefs!.setStringList( + "chats", + (prefs!.getStringList("chats") ?? []).append([ + jsonEncode({ + "title": AppLocalizations.of(context)!.newChatTitle, + "uuid": chatUuid, + "messages": [] + }) + ]).toList()); + } + + var history = await getHistory(addToSystem); + + history.add(llama.Message( + role: llama.MessageRole.user, + content: value.trim(), + images: (images.isNotEmpty) ? images : null)); + messages.insert( + 0, + types.TextMessage( + author: user, id: const Uuid().v4(), text: value.trim())); + + saveChat(chatUuid!, setState); + + setState(() {}); + chatAllowed = false; + + String text = ""; + + String newId = const Uuid().v4(); + llama.OllamaClient client = llama.OllamaClient( + headers: (jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map) + .cast(), + baseUrl: "$host/api"); + + try { + if ((prefs!.getString("requestType") ?? "stream") == "stream") { + final stream = client + .generateChatCompletionStream( + request: llama.GenerateChatCompletionRequest( + model: model!, + messages: history, + keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")), + ) + .timeout(const Duration(seconds: 30)); + + await for (final res in stream) { + text += (res.message?.content ?? ""); + for (var i = 0; i < messages.length; i++) { + if (messages[i].id == newId) { + messages.removeAt(i); + break; + } + } + if (chatAllowed) return ""; + if (text.trim() == "") { + throw Exception(); + } + messages.insert( + 0, types.TextMessage(author: assistant, id: newId, text: text)); + if (onStream != null) { + onStream(text, false); + } + setState(() {}); + heavyHaptic(); + } + } else { + llama.GenerateChatCompletionResponse request; + request = await client + .generateChatCompletion( + request: llama.GenerateChatCompletionRequest( + model: model!, + messages: history, + keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")), + ) + .timeout(const Duration(seconds: 30)); + if (chatAllowed) return ""; + if (request.message!.content.trim() == "") { + throw Exception(); + } + messages.insert( + 0, + types.TextMessage( + author: assistant, id: newId, text: request.message!.content)); + text = request.message!.content; + setState(() {}); + heavyHaptic(); + } + } catch (e) { + for (var i = 0; i < messages.length; i++) { + if (messages[i].id == newId) { + messages.removeAt(i); + break; + } + } + setState(() { + chatAllowed = true; + messages.removeAt(0); + if (messages.isEmpty) { + var tmp = (prefs!.getStringList("chats") ?? []); + chatUuid = null; + for (var i = 0; i < tmp.length; i++) { + if (jsonDecode((prefs!.getStringList("chats") ?? [])[i])["uuid"] == + chatUuid) { + tmp.removeAt(i); + prefs!.setStringList("chats", tmp); + break; + } + } + } + }); + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + // ignore: use_build_context_synchronously + Text(AppLocalizations.of(context)!.settingsHostInvalid("timeout")), + showCloseIcon: true)); + return ""; + } + + if ((prefs!.getString("requestType") ?? "stream") == "stream") { + if (onStream != null) { + onStream(text, true); + } + } + saveChat(chatUuid!, setState); + + if (newChat && (prefs!.getBool("generateTitles") ?? true)) { + void setTitle() async { + history = await getHistory(); + + try { + final generated = await client + .generateCompletion( + request: llama.GenerateCompletionRequest( + model: model!, + prompt: + "You must not use markdown or any other formatting language! Create a short title for the subject of the conversation described in the following json object. It is not allowed to be too general; no 'Assistance', 'Help' or similar!\n\n```json\n${jsonEncode(history)}\n```", + keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")), + ) + .timeout(const Duration(seconds: 10)); + var title = generated.response! + .replaceAll("\"", "") + .replaceAll("'", "") + .replaceAll("*", "") + .replaceAll("_", "") + .trim(); + var tmp = (prefs!.getStringList("chats") ?? []); + for (var i = 0; i < tmp.length; i++) { + if (jsonDecode((prefs!.getStringList("chats") ?? [])[i])["uuid"] == + chatUuid) { + var tmp2 = jsonDecode(tmp[i]); + tmp2["title"] = title; + tmp[i] = jsonEncode(tmp2); + break; + } + } + prefs!.setStringList("chats", tmp); + } catch (_) {} + + setState(() {}); + } + + setTitle(); + } + + setState(() {}); + chatAllowed = true; + return text; +} diff --git a/lib/worker/setter.dart b/lib/worker/setter.dart index 3931046..6131370 100644 --- a/lib/worker/setter.dart +++ b/lib/worker/setter.dart @@ -198,6 +198,7 @@ void setModel(BuildContext context, Function setState) { .colorScheme .primary, onSelected: (bool selected) { + selectionHaptic(); if (addIndex == index) { Navigator.of(context).pop(); ScaffoldMessenger.of(context) diff --git a/pubspec.lock b/pubspec.lock index 532f821..545183e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + datetime_loop: + dependency: "direct main" + description: + name: datetime_loop + sha256: "5b7b694879e505368e6d0e04ac8bb55a1e7d2e493dd0b851e08db08e1ba784e2" + url: "https://pub.dev" + source: hosted + version: "1.2.0" diffutil_dart: dependency: transitive description: @@ -328,6 +336,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: aed2a00c48c43af043ed81145fd8503ddd793dafa7088ab137dbef81a703e53d + url: "https://pub.dev" + source: hosted + version: "4.0.2" flutter_web_plugins: dependency: transitive description: flutter @@ -445,6 +461,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" json_annotation: dependency: transitive description: @@ -590,6 +614,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5" + url: "https://pub.dev" + source: hosted + version: "12.0.6" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" photo_view: dependency: transitive description: @@ -715,6 +795,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + speech_to_text: + dependency: "direct main" + description: + name: speech_to_text + sha256: "97425fd8cc60424061a0584b6c418c0eedab5201cc5e96ef15a946d7fab7b9b7" + url: "https://pub.dev" + source: hosted + version: "6.6.2" + speech_to_text_macos: + dependency: transitive + description: + name: speech_to_text_macos + sha256: e685750f7542fcaa087a5396ee471e727ec648bf681f4da83c84d086322173f6 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a0df1a907091ea09880077dc25aae02af9f79811264e6e97ddb08639b7f771c2 + url: "https://pub.dev" + source: hosted + version: "2.2.0" sprintf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 93069ec..8ce670d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,10 @@ dependencies: version: ^3.0.2 flutter_displaymode: ^0.6.0 duration_picker: ^1.2.0 + speech_to_text: ^6.6.2 + flutter_tts: ^4.0.2 + permission_handler: ^11.3.1 + datetime_loop: ^1.2.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 6cde6bb..08aa015 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f686cae..9f559f3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows file_selector_windows + flutter_tts + permission_handler_windows url_launcher_windows )