From 9444aecfd38d3ee2c604288e21f06b92d79a077c Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:05:00 -0500 Subject: [PATCH] added enabled tts learning setting, give user a warning message when tts not available for target language (#1227) --- assets/l10n/intl_en.arb | 8 +- lib/config/app_config.dart | 5 + lib/pangea/models/space_model.dart | 6 + lib/pangea/models/user_model.dart | 4 + .../settings_learning/settings_learning.dart | 18 ++ .../settings_learning_view.dart | 199 +++++++++++------- lib/pangea/widgets/chat/tts_controller.dart | 79 ++++--- .../p_settings_switch_list_tile.dart | 36 ++-- lib/utils/platform_infos.dart | 5 +- 9 files changed, 236 insertions(+), 124 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 16fe22661..de95f0f28 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4058,7 +4058,7 @@ "roomDataMissing": "Some data may be missing from rooms in which you are not a member.", "updatePhoneOS": "You may need to update your device's OS version.", "wordsPerMinute": "Words per minute", - "autoIGCToolName": "Run Language Assistance Automatically", + "autoIGCToolName": "Run language assistance automatically", "autoIGCToolDescription": "Automatically run language assistance after typing messages", "runGrammarCorrection": "Check message", "grammarCorrectionFailed": "Issues to address", @@ -4623,5 +4623,9 @@ "maxXP": {} } }, - "registrationEmailMessage": "Please verify your email with a link sent there. In some cases, the email takes up to 5 minutes to arrive. Please also check your spam folder." + "registrationEmailMessage": "Please verify your email with a link sent there. In some cases, the email takes up to 5 minutes to arrive. Please also check your spam folder.", + "enableTTSToolName": "Enabled text-to-speech", + "enableTTSToolDescription": "Allow the app to generate text-to-speech output for portions of text in your target language", + "couldNotFindTTS": "We couldn't find a text-to-speech engine for your current target language. ", + "ttsInstructionsHyperlink": "Click here to view instructions for downloading a new voice on your device." } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 5a0da605e..6a5efb9af 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -159,6 +159,11 @@ abstract class AppConfig { static String androidUpdateURL = "https://play.google.com/store/apps/details?id=com.talktolearn.chat"; static String iosUpdateURL = "itms-apps://itunes.apple.com/app/id1445118630"; + + static String windowsTTSDownloadInstructions = + "https://support.microsoft.com/en-us/topic/download-languages-and-voices-for-immersive-reader-read-mode-and-read-aloud-4c83a8d8-7486-42f7-8e46-2b0fdf753130"; + static String androidTTSDownloadInstructions = + "https://support.google.com/accessibility/android/answer/6006983?hl=en"; // Pangea# static void loadFromJson(Map json) { diff --git a/lib/pangea/models/space_model.dart b/lib/pangea/models/space_model.dart index 7f598383c..78abb7f6f 100644 --- a/lib/pangea/models/space_model.dart +++ b/lib/pangea/models/space_model.dart @@ -232,6 +232,7 @@ enum ToolSetting { definitions, // translations, autoIGC, + enableTTS, } extension SettingCopy on ToolSetting { @@ -249,6 +250,8 @@ extension SettingCopy on ToolSetting { // return L10n.of(context).messageTranslationsToolName; case ToolSetting.autoIGC: return L10n.of(context).autoIGCToolName; + case ToolSetting.enableTTS: + return L10n.of(context).enableTTSToolName; } } @@ -267,6 +270,8 @@ extension SettingCopy on ToolSetting { // return L10n.of(context).translationsToolDescrption; case ToolSetting.autoIGC: return L10n.of(context).autoIGCToolDescription; + case ToolSetting.enableTTS: + return L10n.of(context).enableTTSToolDescription; } } @@ -278,6 +283,7 @@ extension SettingCopy on ToolSetting { case ToolSetting.immersionMode: return false; case ToolSetting.autoIGC: + case ToolSetting.enableTTS: return true; } } diff --git a/lib/pangea/models/user_model.dart b/lib/pangea/models/user_model.dart index f3e3531d0..cf4e77b51 100644 --- a/lib/pangea/models/user_model.dart +++ b/lib/pangea/models/user_model.dart @@ -126,6 +126,7 @@ class UserToolSettings { bool immersionMode; bool definitions; bool autoIGC; + bool enableTTS; UserToolSettings({ this.interactiveTranslator = true, @@ -133,6 +134,7 @@ class UserToolSettings { this.immersionMode = false, this.definitions = true, this.autoIGC = true, + this.enableTTS = true, }); factory UserToolSettings.fromJson(Map json) => @@ -144,6 +146,7 @@ class UserToolSettings { immersionMode: false, definitions: json[ToolSetting.definitions.toString()] ?? true, autoIGC: json[ToolSetting.autoIGC.toString()] ?? true, + enableTTS: json[ToolSetting.enableTTS.toString()] ?? true, ); Map toJson() { @@ -153,6 +156,7 @@ class UserToolSettings { data[ToolSetting.immersionMode.toString()] = immersionMode; data[ToolSetting.definitions.toString()] = definitions; data[ToolSetting.autoIGC.toString()] = autoIGC; + data[ToolSetting.enableTTS.toString()] = enableTTS; return data; } diff --git a/lib/pangea/pages/settings_learning/settings_learning.dart b/lib/pangea/pages/settings_learning/settings_learning.dart index 3e0a11e4c..26feede08 100644 --- a/lib/pangea/pages/settings_learning/settings_learning.dart +++ b/lib/pangea/pages/settings_learning/settings_learning.dart @@ -3,6 +3,7 @@ import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/models/user_model.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning_view.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; @@ -16,6 +17,19 @@ class SettingsLearning extends StatefulWidget { class SettingsLearningController extends State { PangeaController pangeaController = MatrixState.pangeaController; + final tts = TtsController(); + + @override + void initState() { + super.initState(); + tts.setupTTS().then((_) => setState(() {})); + } + + @override + void dispose() { + tts.dispose(); + super.dispose(); + } setPublicProfile(bool isPublic) { pangeaController.userController.updateProfile((profile) { @@ -50,6 +64,8 @@ class SettingsLearningController extends State { return profile..toolSettings.definitions = value; case ToolSetting.autoIGC: return profile..toolSettings.autoIGC = value; + case ToolSetting.enableTTS: + return profile..toolSettings.enableTTS = value; } }); } @@ -67,6 +83,8 @@ class SettingsLearningController extends State { return toolSettings.definitions; case ToolSetting.autoIGC: return toolSettings.autoIGC; + case ToolSetting.enableTTS: + return toolSettings.enableTTS; } } diff --git a/lib/pangea/pages/settings_learning/settings_learning_view.dart b/lib/pangea/pages/settings_learning/settings_learning_view.dart index cd48aadee..5bda51b29 100644 --- a/lib/pangea/pages/settings_learning/settings_learning_view.dart +++ b/lib/pangea/pages/settings_learning/settings_learning_view.dart @@ -1,12 +1,18 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart'; import 'package:fluffychat/pangea/widgets/user_settings/country_picker_tile.dart'; import 'package:fluffychat/pangea/widgets/user_settings/language_tile.dart'; import 'package:fluffychat/pangea/widgets/user_settings/p_settings_switch_list_tile.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class SettingsLearningView extends StatelessWidget { final SettingsLearningController controller; @@ -14,89 +20,122 @@ class SettingsLearningView extends StatelessWidget { @override Widget build(BuildContext context) { - final dialogContent = Scaffold( - appBar: AppBar( - centerTitle: true, - title: Text( - L10n.of(context).learningSettings, - ), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: Navigator.of(context).pop, - ), - ), - body: ListTileTheme( - iconColor: Theme.of(context).textTheme.bodyLarge!.color, - child: MaxWidthBody( - withScrolling: true, - child: Column( - children: [ - LanguageTile(controller), - CountryPickerTile(controller), - const Divider(height: 1), - ListTile( - title: Text(L10n.of(context).toggleToolSettingsDescription), + return StreamBuilder( + stream: Matrix.of(context).client.onSync.stream.where((update) { + return update.accountData != null && + update.accountData!.any( + (event) => event.type == ModelKey.userProfile, + ); + }), + builder: (context, _) { + final dialogContent = Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + L10n.of(context).learningSettings, + ), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: Navigator.of(context).pop, + ), + ), + body: ListTileTheme( + iconColor: Theme.of(context).textTheme.bodyLarge!.color, + child: MaxWidthBody( + withScrolling: true, + child: Column( + children: [ + LanguageTile(controller), + CountryPickerTile(controller), + const Divider(height: 1), + ListTile( + title: Text(L10n.of(context).toggleToolSettingsDescription), + ), + for (final toolSetting in ToolSetting.values + .where((tool) => tool.isAvailableSetting)) + Column( + children: [ + ProfileSettingsSwitchListTile.adaptive( + defaultValue: controller.getToolSetting(toolSetting), + title: toolSetting.toolName(context), + subtitle: toolSetting == ToolSetting.enableTTS && + !controller.tts.isLanguageFullySupported + ? null + : toolSetting.toolDescription(context), + onChange: (bool value) => + controller.updateToolSetting( + toolSetting, + value, + ), + enabled: toolSetting == ToolSetting.enableTTS + ? controller.tts.isLanguageFullySupported + : true, + ), + if (toolSetting == ToolSetting.enableTTS && + !controller.tts.isLanguageFullySupported) + ListTile( + trailing: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Icon(Icons.info_outlined), + ), + subtitle: RichText( + text: TextSpan( + text: L10n.of(context).couldNotFindTTS, + style: DefaultTextStyle.of(context).style, + children: [ + if (PlatformInfos.isWindows || + PlatformInfos.isAndroid) + TextSpan( + text: L10n.of(context) + .ttsInstructionsHyperlink, + style: const TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlString( + PlatformInfos.isWindows + ? AppConfig + .windowsTTSDownloadInstructions + : AppConfig + .androidTTSDownloadInstructions, + ); + }, + ), + ], + ), + ), + ), + ], + ), + ], ), - for (final toolSetting in ToolSetting.values - .where((tool) => tool.isAvailableSetting)) - ProfileSettingsSwitchListTile.adaptive( - defaultValue: controller.getToolSetting(toolSetting), - title: toolSetting.toolName(context), - subtitle: toolSetting.toolDescription(context), - onChange: (bool value) => controller.updateToolSetting( - toolSetting, - value, + ), + ), + ); + + return kIsWeb + ? Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 600, + maxHeight: 600, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: dialogContent, ), ), - // ProfileSettingsSwitchListTile.adaptive( - // defaultValue: controller.pangeaController.userController.profile - // .userSettings.itAutoPlay, - // title: - // L10n.of(context).interactiveTranslatorAutoPlaySliderHeader, - // subtitle: L10n.of(context).interactiveTranslatorAutoPlayDesc, - // onChange: (bool value) => controller - // .pangeaController.userController - // .updateProfile((profile) { - // profile.userSettings.itAutoPlay = value; - // return profile; - // }), - // ), - // ProfileSettingsSwitchListTile.adaptive( - // defaultValue: controller.pangeaController.userController.profile - // .userSettings.autoPlayMessages, - // title: L10n.of(context).autoPlayTitle, - // subtitle: L10n.of(context).autoPlayDesc, - // onChange: (bool value) => controller - // .pangeaController.userController - // .updateProfile((profile) { - // profile.userSettings.autoPlayMessages = value; - // return profile; - // }), - // ), - ], - ), - ), - ), + ) + : Dialog.fullscreen( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: dialogContent, + ), + ); + }, ); - - return kIsWeb - ? Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 600, - maxHeight: 600, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: dialogContent, - ), - ), - ) - : Dialog.fullscreen( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: dialogContent, - ), - ); } } diff --git a/lib/pangea/widgets/chat/tts_controller.dart b/lib/pangea/widgets/chat/tts_controller.dart index 5a7bbc3e8..3e3f96991 100644 --- a/lib/pangea/widgets/chat/tts_controller.dart +++ b/lib/pangea/widgets/chat/tts_controller.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:developer'; +import 'package:fluffychat/pangea/controllers/user_controller.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/missing_voice_button.dart'; @@ -18,17 +20,24 @@ class TtsController { List _availableLangCodes = []; final flutter_tts.FlutterTts _tts = flutter_tts.FlutterTts(); final TextToSpeech _alternativeTTS = TextToSpeech(); + StreamSubscription? _languageSubscription; + + UserController get userController => + MatrixState.pangeaController.userController; TtsController() { setupTTS(); + _languageSubscription = + userController.stateStream.listen((_) => setupTTS()); } bool get _useAlternativeTTS { - return PlatformInfos.getOperatingSystem() == 'Windows'; + return PlatformInfos.isWindows; } Future dispose() async { await _tts.stop(); + await _languageSubscription?.cancel(); } void _onError(dynamic message) => ErrorHandler.logError( @@ -40,39 +49,46 @@ class TtsController { ); Future setupTTS() async { - if (_useAlternativeTTS) { - await _setupAltTTS(); - return; - } - try { - _tts.setErrorHandler(_onError); - debugger(when: kDebugMode && targetLanguage == null); + if (_useAlternativeTTS) { + await _setupAltTTS(); + } else { + _tts.setErrorHandler(_onError); + debugger(when: kDebugMode && targetLanguage == null); - _tts.setLanguage( - targetLanguage ?? "en", - ); + _tts.setLanguage( + targetLanguage ?? "en", + ); - await _tts.awaitSpeakCompletion(true); + await _tts.awaitSpeakCompletion(true); - final voices = (await _tts.getVoices) as List?; - _availableLangCodes = (voices ?? []) - .map((v) { - // on iOS / web, the codes are in 'locale', but on Android, they are in 'name' - final nameCode = v['name']?.split("-").first; - final localeCode = v['locale']?.split("-").first; - return nameCode.length == 2 ? nameCode : localeCode; - }) - .toSet() - .cast() - .toList(); - - debugPrint("availableLangCodes: $_availableLangCodes"); - - debugger(when: kDebugMode && !_isLanguageFullySupported); + final voices = (await _tts.getVoices) as List?; + _availableLangCodes = (voices ?? []) + .map((v) { + // on iOS / web, the codes are in 'locale', but on Android, they are in 'name' + final nameCode = v['name']?.split("-").first; + final localeCode = v['locale']?.split("-").first; + return nameCode.length == 2 ? nameCode : localeCode; + }) + .toSet() + .cast() + .toList(); + } } catch (e, s) { debugger(when: kDebugMode); ErrorHandler.logError(e: e, s: s); + } finally { + debugPrint("availableLangCodes: $_availableLangCodes"); + final enableTTSSetting = userController.profile.toolSettings.enableTTS; + if (enableTTSSetting != isLanguageFullySupported) { + await userController.updateProfile( + (profile) { + profile.toolSettings.enableTTS = isLanguageFullySupported; + return profile; + }, + waitForDataInSync: true, + ); + } } } @@ -148,7 +164,12 @@ class TtsController { // TODO - make non-nullable again String? eventID, ) async { - if (_isLanguageFullySupported) { + if (!MatrixState + .pangeaController.userController.profile.toolSettings.enableTTS) { + return; + } + + if (isLanguageFullySupported) { await _speak(text); } else { ErrorHandler.logError( @@ -200,6 +221,6 @@ class TtsController { } } - bool get _isLanguageFullySupported => + bool get isLanguageFullySupported => _availableLangCodes.contains(targetLanguage); } diff --git a/lib/pangea/widgets/user_settings/p_settings_switch_list_tile.dart b/lib/pangea/widgets/user_settings/p_settings_switch_list_tile.dart index 2649b28e2..e6473a425 100644 --- a/lib/pangea/widgets/user_settings/p_settings_switch_list_tile.dart +++ b/lib/pangea/widgets/user_settings/p_settings_switch_list_tile.dart @@ -7,12 +7,14 @@ class ProfileSettingsSwitchListTile extends StatefulWidget { final String title; final String? subtitle; final Function(bool) onChange; + final bool enabled; const ProfileSettingsSwitchListTile.adaptive({ super.key, required this.defaultValue, required this.title, required this.onChange, + this.enabled = true, this.subtitle, }); @@ -30,6 +32,14 @@ class PSettingsSwitchListTileState super.initState(); } + @override + void didUpdateWidget(ProfileSettingsSwitchListTile oldWidget) { + if (oldWidget.defaultValue != widget.defaultValue) { + setState(() => currentValue = widget.defaultValue); + } + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { return SwitchListTile.adaptive( @@ -37,18 +47,20 @@ class PSettingsSwitchListTileState title: Text(widget.title), activeColor: AppConfig.activeToggleColor, subtitle: widget.subtitle != null ? Text(widget.subtitle!) : null, - onChanged: (bool newValue) async { - try { - widget.onChange(newValue); - setState(() => currentValue = newValue); - } catch (err, s) { - ErrorHandler.logError( - e: err, - m: "Failed to updates user setting", - s: s, - ); - } - }, + onChanged: widget.enabled + ? (bool newValue) async { + try { + widget.onChange(newValue); + setState(() => currentValue = newValue); + } catch (err, s) { + ErrorHandler.logError( + e: err, + m: "Failed to updates user setting", + s: s, + ); + } + } + : null, ); } } diff --git a/lib/utils/platform_infos.dart b/lib/utils/platform_infos.dart index cb8eda108..dbae48db0 100644 --- a/lib/utils/platform_infos.dart +++ b/lib/utils/platform_infos.dart @@ -13,7 +13,10 @@ import '../config/app_config.dart'; abstract class PlatformInfos { static bool get isWeb => kIsWeb; static bool get isLinux => !kIsWeb && Platform.isLinux; - static bool get isWindows => !kIsWeb && Platform.isWindows; + // #Pangea + // static bool get isWindows => !kIsWeb && Platform.isWindows; + static bool get isWindows => getOperatingSystem() == 'Windows'; + // Pangea# static bool get isMacOS => !kIsWeb && Platform.isMacOS; static bool get isIOS => !kIsWeb && Platform.isIOS; static bool get isAndroid => !kIsWeb && Platform.isAndroid;