diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index a1557af98..2b3d07a94 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5325,5 +5325,6 @@ "enabledRenewal": "Enable Subscription Renewal", "subscriptionEndsOn": "Subscription Ends On", "subscriptionRenewsOn": "Subscription Renews On", - "waitForSubscriptionChanges": "Changes to your subscription may take a moment to reflect in the app." + "waitForSubscriptionChanges": "Changes to your subscription may take a moment to reflect in the app.", + "subscribeReadingAssistance": "Subscribe to unlock message tools" } \ No newline at end of file diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 24827cb29..83b0f33ac 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -19,7 +19,6 @@ import 'package:fluffychat/pangea/message_token_text/tokens_util.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/utils/token_rendering_util.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; import 'package:fluffychat/utils/event_checkbox_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -441,14 +440,14 @@ class HtmlMessage extends StatelessWidget { : PlaceholderAlignment.middle, child: Column( children: [ - if (token != null && - overlayController?.selectedMode == SelectMode.emoji) + if (token != null && overlayController != null) TokenEmojiButton( - token: token, - eventId: event.eventId, + enabled: token.lemma.saveVocab, + emoji: token.vocabConstructID.userSetEmoji.firstOrNull, targetId: overlayController!.tokenEmojiPopupKey(token), onSelect: () => overlayController!.showTokenEmojiPopup(token), + selectModeNotifier: overlayController!.selectedMode, ), if (renderer.showCenterStyling && token != null) TokenPracticeButton( @@ -942,11 +941,11 @@ class HtmlMessage extends StatelessWidget { : PlaceholderAlignment.middle, child: Column( children: [ - if (node.localName == 'nontoken' && - overlayController?.selectedMode == SelectMode.emoji) + if (node.localName == 'nontoken' && overlayController != null) + // Use TokenEmojiButton to ensure consistent vertical alignment for non-token elements (e.g., emojis) in practice mode. TokenEmojiButton( - token: null, - eventId: event.eventId, + selectModeNotifier: overlayController!.selectedMode, + enabled: false, ), RichText( text: TextSpan( diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart new file mode 100644 index 000000000..91c4e36db --- /dev/null +++ b/lib/pangea/common/utils/async_state.dart @@ -0,0 +1,55 @@ +/// A generic sealed class that represents the state of an asynchronous operation. +sealed class AsyncState { + /// Base constructor for all asynchronous state variants. + const AsyncState(); + + /// Represents an idle state before any asynchronous work has begun. + const factory AsyncState.idle() = AsyncIdle; + + /// Represents an in-progress loading state. + const factory AsyncState.loading() = AsyncLoading; + + /// Represents a completed asynchronous operation with a successful [value]. + const factory AsyncState.loaded(T value) = AsyncLoaded; + + /// Represents a failed asynchronous operation with an [error]. + const factory AsyncState.error(Object error) = AsyncError; +} + +/// The idle state of an [AsyncState], indicating no active or completed work. +/// +/// Use this as the initial state before triggering an async operation. +class AsyncIdle extends AsyncState { + /// Creates an idle [AsyncState]. + const AsyncIdle(); +} + +/// The loading state of an [AsyncState], indicating that work is in progress. +/// +/// This state is typically used to show a loading spinner or progress indicator. +class AsyncLoading extends AsyncState { + /// Creates a loading [AsyncState]. + const AsyncLoading(); +} + +/// The success state of an [AsyncState], containing a completed [value]. +/// +/// This state indicates that the asynchronous work finished successfully. +class AsyncLoaded extends AsyncState { + /// The result of the successful asynchronous operation. + final T value; + + /// Creates a loaded [AsyncState] with a [value]. + const AsyncLoaded(this.value); +} + +/// The error state of an [AsyncState], containing an [error]. +/// +/// This state indicates that the asynchronous work failed. +class AsyncError extends AsyncState { + /// The error produced during the asynchronous operation. + final Object error; + + /// Creates an error [AsyncState] with an [error]. + const AsyncError(this.error); +} diff --git a/lib/pangea/message_token_text/token_emoji_button.dart b/lib/pangea/message_token_text/token_emoji_button.dart index 449f0d14d..e1fab172e 100644 --- a/lib/pangea/message_token_text/token_emoji_button.dart +++ b/lib/pangea/message_token_text/token_emoji_button.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; import 'package:fluffychat/widgets/matrix.dart'; class TokenEmojiButton extends StatefulWidget { - final PangeaToken? token; - final String eventId; + final ValueNotifier selectModeNotifier; + final bool enabled; + final String? emoji; final String? targetId; final VoidCallback? onSelect; const TokenEmojiButton({ super.key, - required this.token, - required this.eventId, + required this.selectModeNotifier, + this.enabled = true, + this.emoji, this.targetId, this.onSelect, }); @@ -25,12 +27,30 @@ class TokenEmojiButton extends StatefulWidget { class TokenEmojiButtonState extends State with TickerProviderStateMixin { final double buttonSize = 20.0; + SelectMode? _prevMode; AnimationController? _controller; Animation? _sizeAnimation; @override void initState() { super.initState(); + _initAnimation(); + _prevMode = widget.selectModeNotifier.value; + widget.selectModeNotifier.addListener(_onUpdateSelectMode); + } + + @override + void dispose() { + _controller?.dispose(); + widget.selectModeNotifier.removeListener(_onUpdateSelectMode); + super.dispose(); + } + + void _initAnimation() { + if (MatrixState.pangeaController.subscriptionController.isSubscribed == + false) { + return; + } _controller = AnimationController( vsync: this, @@ -41,60 +61,72 @@ class TokenEmojiButtonState extends State begin: 0, end: buttonSize, ).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut)); - - _controller?.forward(); } - @override - void dispose() { - _controller?.dispose(); - super.dispose(); + void _onUpdateSelectMode() { + final mode = widget.selectModeNotifier.value; + if (_prevMode != SelectMode.emoji && mode == SelectMode.emoji) { + _controller?.forward(); + } else if (_prevMode == SelectMode.emoji && mode != SelectMode.emoji) { + _controller?.reverse(); + } + _prevMode = mode; } @override Widget build(BuildContext context) { - final eligible = widget.token?.lemma.saveVocab ?? false; - final emoji = widget.token?.vocabConstructID.userSetEmoji.firstOrNull; - if (_sizeAnimation != null) { - final content = AnimatedBuilder( - key: widget.targetId != null - ? MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).key - : null, - animation: _sizeAnimation!, - builder: (context, child) { - return Container( - height: _sizeAnimation!.value, - width: eligible ? _sizeAnimation!.value : 0, - alignment: Alignment.center, - child: eligible - ? InkWell( - onTap: widget.onSelect, - borderRadius: BorderRadius.circular(99.0), - child: emoji != null - ? Text( - emoji, - style: TextStyle(fontSize: buttonSize - 4.0), - textScaler: TextScaler.noScaling, - ) - : Icon( - Icons.add_reaction_outlined, - size: buttonSize - 4.0, - color: Theme.of(context).colorScheme.primary, - ), - ) - : null, - ); - }, - ); - return widget.targetId != null - ? CompositedTransformTarget( - link: - MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link, - child: content, - ) - : content; + if (_sizeAnimation == null) { + return const SizedBox.shrink(); } - return const SizedBox.shrink(); + final child = widget.enabled + ? InkWell( + onTap: widget.onSelect, + borderRadius: BorderRadius.circular(99.0), + child: widget.emoji != null + ? Text( + widget.emoji!, + style: TextStyle(fontSize: buttonSize - 4.0), + textScaler: TextScaler.noScaling, + ) + : Icon( + Icons.add_reaction_outlined, + size: buttonSize - 4.0, + color: Theme.of(context).colorScheme.primary, + ), + ) + : null; + + final content = ValueListenableBuilder( + valueListenable: widget.selectModeNotifier, + builder: (context, mode, __) { + return mode == SelectMode.emoji + ? AnimatedBuilder( + key: widget.targetId != null + ? MatrixState.pAnyState + .layerLinkAndKey(widget.targetId!) + .key + : null, + animation: _sizeAnimation!, + child: child, + builder: (context, child) { + return Container( + height: _sizeAnimation!.value, + width: widget.enabled ? _sizeAnimation!.value : 0, + alignment: Alignment.center, + child: child, + ); + }, + ) + : const SizedBox(); + }, + ); + + return widget.targetId != null + ? CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link, + child: content, + ) + : content; } } diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart index 59d71d1d2..be1a1869c 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart @@ -22,8 +22,6 @@ class PhoneticTranscriptionWidget extends StatefulWidget { final double? iconSize; final Color? iconColor; - final bool enabled; - final VoidCallback? onTranscriptionFetched; const PhoneticTranscriptionWidget({ @@ -33,7 +31,6 @@ class PhoneticTranscriptionWidget extends StatefulWidget { this.style, this.iconSize, this.iconColor, - this.enabled = true, this.onTranscriptionFetched, }); @@ -141,78 +138,71 @@ class _PhoneticTranscriptionWidgetState @override Widget build(BuildContext context) { - return IgnorePointer( - ignoring: !widget.enabled, - child: HoverBuilder( - builder: (context, hovering) { - return GestureDetector( - onTap: () => _handleAudioTap(context), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: hovering - ? Colors.grey.withAlpha((0.2 * 255).round()) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_error != null) - _error is UnsubscribedException - ? ErrorIndicator( - message: L10n.of(context) - .subscribeToUnlockTranscriptions, - onTap: () { - MatrixState - .pangeaController.subscriptionController - .showPaywall(context); - }, - ) - : ErrorIndicator( - message: - L10n.of(context).failedToFetchTranscription, - ) - else if (_isLoading || _transcription == null) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(), - ) - else - Flexible( - child: Text( - _transcription!, - textScaler: TextScaler.noScaling, - style: widget.style ?? - Theme.of(context).textTheme.bodyMedium, - ), - ), - if (_transcription != null && - _error == null && - widget.enabled) - const SizedBox(width: 8), - if (_transcription != null && - _error == null && - widget.enabled) - Tooltip( - message: _isPlaying - ? L10n.of(context).stop - : L10n.of(context).playAudio, - child: Icon( - _isPlaying ? Icons.pause_outlined : Icons.volume_up, - size: widget.iconSize ?? 24, - color: widget.iconColor ?? - Theme.of(context).iconTheme.color, - ), - ), - ], - ), + return HoverBuilder( + builder: (context, hovering) { + return GestureDetector( + onTap: () => _handleAudioTap(context), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: hovering + ? Colors.grey.withAlpha((0.2 * 255).round()) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), ), - ); - }, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_error != null) + _error is UnsubscribedException + ? ErrorIndicator( + message: + L10n.of(context).subscribeToUnlockTranscriptions, + onTap: () { + MatrixState.pangeaController.subscriptionController + .showPaywall(context); + }, + style: widget.style, + ) + : ErrorIndicator( + message: L10n.of(context).failedToFetchTranscription, + style: widget.style, + ) + else if (_isLoading || _transcription == null) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(), + ) + else + Flexible( + child: Text( + _transcription!, + textScaler: TextScaler.noScaling, + style: widget.style ?? + Theme.of(context).textTheme.bodyMedium, + ), + ), + if (_transcription != null && _error == null) + const SizedBox(width: 8), + if (_transcription != null && _error == null) + Tooltip( + message: _isPlaying + ? L10n.of(context).stop + : L10n.of(context).playAudio, + child: Icon( + _isPlaying ? Icons.pause_outlined : Icons.volume_up, + size: widget.iconSize ?? 24, + color: + widget.iconColor ?? Theme.of(context).iconTheme.color, + ), + ), + ], + ), + ), + ); + }, ); } } diff --git a/lib/pangea/practice_activities/practice_selection.dart b/lib/pangea/practice_activities/practice_selection.dart index 00f8b7a2a..d67555959 100644 --- a/lib/pangea/practice_activities/practice_selection.dart +++ b/lib/pangea/practice_activities/practice_selection.dart @@ -187,8 +187,6 @@ class PracticeSelection { activityTokens.add(t); } - debugPrint("TOKENS: ${activityTokens.map((e) => e.text.content).toList()}"); - return [ PracticeTarget( activityType: activityType, diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index fe928f2a9..7b130d1c0 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:collection/collection.dart'; -import 'package:matrix/matrix.dart'; +import 'package:matrix/matrix.dart' hide Result; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; @@ -32,10 +32,10 @@ import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller. import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart'; import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -92,20 +92,11 @@ class MessageOverlayController extends State ReadingAssistanceMode? readingAssistanceMode; // default mode - SpeechToTextModel? transcription; - String? transcriptionError; - - bool showTranslation = false; - String? translation; - - bool showSpeechTranslation = false; - String? speechTranslation; - - final StreamController contentChangedStream = StreamController.broadcast(); - double maxWidth = AppConfig.toolbarMinWidth; - SelectMode? selectedMode; + late SelectModeController selectModeController; + ValueNotifier get selectedMode => + selectModeController.selectedMode; ///////////////////////////////////// /// Lifecycle @@ -114,6 +105,7 @@ class MessageOverlayController extends State @override void initState() { super.initState(); + selectModeController = SelectModeController(pangeaMessageEvent); initializeTokensAndMode(); WidgetsBinding.instance.addPostFrameCallback( (_) => widget.chatController.setSelectedEvent(event), @@ -125,7 +117,7 @@ class MessageOverlayController extends State WidgetsBinding.instance.addPostFrameCallback( (_) => widget.chatController.clearSelectedEvents(), ); - contentChangedStream.close(); + selectModeController.dispose(); super.dispose(); } @@ -257,13 +249,18 @@ class MessageOverlayController extends State /// Update [selectedSpan] void updateSelectedSpan(PangeaTokenText? selectedSpan) { + if (MatrixState.pangeaController.subscriptionController.isSubscribed == + false) { + return; + } + if (selectedSpan == _selectedSpan) return; if (selectedMorph != null) { selectedMorph = null; } _selectedSpan = selectedSpan; - if (selectedMode == SelectMode.emoji && selectedToken != null) { + if (selectedMode.value == SelectMode.emoji && selectedToken != null) { showTokenEmojiPopup(selectedToken!); } if (mounted) { @@ -390,12 +387,6 @@ class MessageOverlayController extends State ?.firstWhereOrNull(isTokenSelected); } - bool get showingExtraContent => - (showTranslation && translation != null) || - (showSpeechTranslation && speechTranslation != null) || - transcription != null || - transcriptionError != null; - bool get showLanguageAssistance { if (!event.status.isSent || event.type != EventTypes.Message) { return false; @@ -543,75 +534,6 @@ class MessageOverlayController extends State ); } - void setSelectMode(SelectMode? mode) { - if (!mounted) return; - if (selectedMode == mode) return; - setState(() => selectedMode = mode); - } - - void setTranslation(String value) { - if (mounted) { - setState(() { - translation = value; - contentChangedStream.add(true); - }); - } - } - - void setShowTranslation(bool show) { - if (!mounted) return; - if (translation == null) { - setState(() => showTranslation = false); - } - - if (showTranslation == show) return; - setState(() { - showTranslation = show; - contentChangedStream.add(true); - }); - } - - void setSpeechTranslation(String value) { - if (mounted) { - setState(() { - speechTranslation = value; - contentChangedStream.add(true); - }); - } - } - - void setShowSpeechTranslation(bool show) { - if (!mounted) return; - if (speechTranslation == null) { - setState(() => showSpeechTranslation = false); - } - - if (showSpeechTranslation == show) return; - setState(() { - showSpeechTranslation = show; - contentChangedStream.add(true); - }); - } - - void setTranscription(SpeechToTextModel value) { - if (mounted) { - setState(() { - transcriptionError = null; - transcription = value; - contentChangedStream.add(true); - }); - } - } - - void setTranscriptionError(String value) { - if (mounted) { - setState(() { - transcriptionError = value; - contentChangedStream.add(true); - }); - } - } - void showTokenEmojiPopup( PangeaToken token, ) { diff --git a/lib/pangea/toolbar/widgets/message_selection_positioner.dart b/lib/pangea/toolbar/widgets/message_selection_positioner.dart index b22450b96..240f80b64 100644 --- a/lib/pangea/toolbar/widgets/message_selection_positioner.dart +++ b/lib/pangea/toolbar/widgets/message_selection_positioner.dart @@ -100,7 +100,7 @@ class MessageSelectionPositionerState extends State ).listen((_) => setState(() {})); _contentChangedSubscription = widget - .overlayController.contentChangedStream.stream + .overlayController.selectModeController.contentChangedStream.stream .listen(_onContentSizeChanged); } @@ -370,7 +370,12 @@ class MessageSelectionPositionerState extends State } } - void setReadingAssistanceMode(ReadingAssistanceMode mode) { + void launchPractice(ReadingAssistanceMode mode) { + if (MatrixState.pangeaController.subscriptionController.isSubscribed == + false) { + return; + } + if (mounted) { setState(() => readingAssistanceMode = mode); } diff --git a/lib/pangea/toolbar/widgets/over_message_overlay.dart b/lib/pangea/toolbar/widgets/over_message_overlay.dart index cd4705ce9..da3b2e652 100644 --- a/lib/pangea/toolbar/widgets/over_message_overlay.dart +++ b/lib/pangea/toolbar/widgets/over_message_overlay.dart @@ -48,36 +48,40 @@ class OverMessageOverlay extends StatelessWidget { 'overlay_message_${controller.widget.event.eventId}', ) .link, - child: OverlayCenterContent( - event: controller.widget.event, - messageHeight: - controller.widget.overlayController.selectedMode != - SelectMode.emoji + child: ValueListenableBuilder( + valueListenable: + controller.widget.overlayController.selectedMode, + builder: (context, mode, __) { + return OverlayCenterContent( + event: controller.widget.event, + messageHeight: mode != SelectMode.emoji ? controller.originalMessageSize.height : null, - messageWidth: - controller.widget.overlayController.showingExtraContent + messageWidth: controller.widget.overlayController + .selectModeController.showingExtraContent ? max(controller.originalMessageSize.width, 150) : controller.originalMessageSize.width, - overlayController: controller.widget.overlayController, - chatController: controller.widget.chatController, - nextEvent: controller.widget.nextEvent, - prevEvent: controller.widget.prevEvent, - hasReactions: controller.hasReactions, - isTransitionAnimation: true, - readingAssistanceMode: controller.readingAssistanceMode, - overlayKey: MatrixState.pAnyState - .layerLinkAndKey( - 'overlay_message_${controller.widget.event.eventId}', - ) - .key, + overlayController: controller.widget.overlayController, + chatController: controller.widget.chatController, + nextEvent: controller.widget.nextEvent, + prevEvent: controller.widget.prevEvent, + hasReactions: controller.hasReactions, + isTransitionAnimation: true, + readingAssistanceMode: controller.readingAssistanceMode, + overlayKey: MatrixState.pAnyState + .layerLinkAndKey( + 'overlay_message_${controller.widget.event.eventId}', + ) + .key, + ); + }, ), ), const SizedBox(height: 4.0), SelectModeButtons( controller: controller.widget.chatController, overlayController: controller.widget.overlayController, - lauchPractice: () => controller.setReadingAssistanceMode( + launchPractice: () => controller.launchPractice( ReadingAssistanceMode.practiceMode, ), ), diff --git a/lib/pangea/toolbar/widgets/overlay_message.dart b/lib/pangea/toolbar/widgets/overlay_message.dart index 9b45c4559..e0d9182ba 100644 --- a/lib/pangea/toolbar/widgets/overlay_message.dart +++ b/lib/pangea/toolbar/widgets/overlay_message.dart @@ -10,20 +10,23 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_content.dart'; import 'package:fluffychat/pages/chat/events/reply_content.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/stt_transcript_tokens.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; -// @ggurdin be great to explain the need/function of a widget like this class OverlayMessage extends StatelessWidget { final Event event; final MessageOverlayController overlayController; @@ -137,144 +140,7 @@ class OverlayMessage extends StatelessWidget { final isSubscribed = MatrixState.pangeaController.subscriptionController.isSubscribed; - final showTranslation = overlayController.showTranslation && - overlayController.translation != null && - isSubscribed != false; - - final showTranscription = - overlayController.pangeaMessageEvent.isAudioMessage == true && - isSubscribed != false; - - final showSpeechTranslation = overlayController.showSpeechTranslation && - overlayController.speechTranslation != null && - isSubscribed != false; - - final transcription = showTranscription - ? Container( - constraints: BoxConstraints( - maxWidth: min( - FluffyThemes.columnWidth * 1.5, - MediaQuery.of(context).size.width - - (ownMessage ? 0 : Avatar.defaultSize) - - 32.0 - - (FluffyThemes.isColumnMode(context) - ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth - : 0.0), - ), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: overlayController.transcriptionError != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(width: 8), - Text( - L10n.of(context).transcriptionFailed, - textScaler: TextScaler.noScaling, - style: AppConfig.messageTextStyle( - event, - textColor, - ).copyWith(fontStyle: FontStyle.italic), - ), - ], - ) - : overlayController.transcription != null - ? SingleChildScrollView( - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - SttTranscriptTokens( - model: overlayController.transcription!, - style: AppConfig.messageTextStyle( - event, - textColor, - ).copyWith( - fontStyle: FontStyle.italic, - ), - onClick: overlayController - .onClickOverlayMessageToken, - isSelected: overlayController.isTokenSelected, - ), - if (MatrixState.pangeaController - .languageController.showTranscription) - PhoneticTranscriptionWidget( - text: overlayController - .transcription!.transcript.text, - textLanguage: PLanguageStore.byLangCode( - overlayController - .transcription!.langCode, - ) ?? - LanguageModel.unknown, - style: AppConfig.messageTextStyle( - event, - textColor, - ), - iconColor: textColor, - enabled: - event.senderId != BotName.byEnvironment, - onTranscriptionFetched: () => - overlayController.contentChangedStream - .add(true), - ), - ], - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator.adaptive( - backgroundColor: textColor, - ), - ], - ), - ), - ) - : const SizedBox(); - - final translation = showTranslation || showSpeechTranslation - ? Container( - constraints: BoxConstraints( - maxWidth: min( - FluffyThemes.columnWidth * 1.5, - MediaQuery.of(context).size.width - - (ownMessage ? 0 : Avatar.defaultSize) - - 32.0 - - (FluffyThemes.isColumnMode(context) - ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth - : 0.0), - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB( - 12.0, - 20.0, - 12.0, - 12.0, - ), - child: SingleChildScrollView( - child: Text( - showTranslation - ? overlayController.translation! - : overlayController.speechTranslation!, - textScaler: TextScaler.noScaling, - style: AppConfig.messageTextStyle( - event, - textColor, - ).copyWith( - fontStyle: FontStyle.italic, - ), - ), - ), - ), - ) - : const SizedBox(); + final selectModeController = overlayController.selectModeController; final content = Container( decoration: BoxDecoration( @@ -392,6 +258,21 @@ class OverlayMessage extends StatelessWidget { ), ); + final maxWidth = min( + FluffyThemes.columnWidth * 1.5, + MediaQuery.of(context).size.width - + (ownMessage ? 0 : Avatar.defaultSize) - + 32.0 - + (FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth + : 0.0), + ); + + final style = AppConfig.messageTextStyle( + event, + textColor, + ); + return Material( type: MaterialType.transparency, child: Container( @@ -408,7 +289,16 @@ class OverlayMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - transcription, + _MessageBubbleTranscription( + controller: selectModeController, + enabled: event.messageType == MessageTypes.Audio && + !event.redacted && + isSubscribed != false, + maxWidth: maxWidth, + style: style, + onTokenSelected: overlayController.onClickOverlayMessageToken, + isTokenSelected: overlayController.isTokenSelected, + ), sizeAnimation != null ? AnimatedBuilder( animation: sizeAnimation!, @@ -421,7 +311,11 @@ class OverlayMessage extends StatelessWidget { }, ) : content, - translation, + _MessageSelectModeContent( + controller: selectModeController, + style: style, + maxWidth: maxWidth, + ), ], ), ), @@ -429,3 +323,196 @@ class OverlayMessage extends StatelessWidget { ); } } + +class _MessageSelectModeContent extends StatelessWidget { + final SelectModeController controller; + final TextStyle style; + final double maxWidth; + + const _MessageSelectModeContent({ + required this.controller, + required this.style, + required this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge( + [ + controller.selectedMode, + controller.currentModeStateNotifier, + ], + ), + builder: (context, _) { + final mode = controller.selectedMode.value; + if (mode == null) { + return const SizedBox(); + } + + final sub = MatrixState.pangeaController.subscriptionController; + if (sub.isSubscribed == false) { + return Padding( + padding: const EdgeInsets.all(12.0), + child: ErrorIndicator( + message: L10n.of(context).subscribeReadingAssistance, + onTap: () => sub.showPaywall(context), + style: style, + ), + ); + } + + if (![ + SelectMode.translate, + SelectMode.speechTranslation, + ].contains(mode)) { + return const SizedBox(); + } + + final AsyncState state = mode == SelectMode.translate + ? controller.translationState.value + : controller.speechTranslationState.value; + + return Padding( + padding: const EdgeInsets.all(12.0), + child: switch (state) { + AsyncLoading() => Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator.adaptive( + backgroundColor: style.color, + ), + ], + ), + AsyncError(error: final _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Text( + L10n.of(context).translationError, + textScaler: TextScaler.noScaling, + style: style.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + AsyncLoaded(value: final value) => Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: SingleChildScrollView( + child: Text( + value, + textScaler: TextScaler.noScaling, + style: style.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ), + _ => const SizedBox(), + }, + ); + }, + ); + } +} + +class _MessageBubbleTranscription extends StatelessWidget { + final SelectModeController controller; + final bool enabled; + final double maxWidth; + final TextStyle style; + + final Function(PangeaToken) onTokenSelected; + final bool Function(PangeaToken) isTokenSelected; + + const _MessageBubbleTranscription({ + required this.controller, + required this.enabled, + required this.maxWidth, + required this.style, + required this.onTokenSelected, + required this.isTokenSelected, + }); + + @override + Widget build(BuildContext context) { + if (!enabled) { + return const SizedBox(); + } + + return Container( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: ValueListenableBuilder( + valueListenable: controller.transcriptionState, + builder: (context, transcriptionState, _) { + switch (transcriptionState) { + case AsyncLoading(): + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator.adaptive( + backgroundColor: style.color, + ), + ], + ); + case AsyncError(error: final _): + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Text( + L10n.of(context).transcriptionFailed, + textScaler: TextScaler.noScaling, + style: style.copyWith(fontStyle: FontStyle.italic), + ), + ], + ); + case AsyncLoaded(value: final transcription): + return SingleChildScrollView( + child: Column( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SttTranscriptTokens( + model: transcription, + style: style.copyWith(fontStyle: FontStyle.italic), + onClick: onTokenSelected, + isSelected: isTokenSelected, + ), + if (MatrixState.pangeaController.languageController + .showTranscription) + PhoneticTranscriptionWidget( + text: transcription.transcript.text, + textLanguage: PLanguageStore.byLangCode( + transcription.langCode, + ) ?? + LanguageModel.unknown, + style: style, + iconColor: style.color, + onTranscriptionFetched: () => + controller.contentChangedStream.add(true), + ), + ], + ), + ); + default: + return const SizedBox(); + } + }, + ), + ), + ); + } +} diff --git a/lib/pangea/toolbar/widgets/select_mode_buttons.dart b/lib/pangea/toolbar/widgets/select_mode_buttons.dart index 85cdba00c..16c1aa9fe 100644 --- a/lib/pangea/toolbar/widgets/select_mode_buttons.dart +++ b/lib/pangea/toolbar/widgets/select_mode_buttons.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; @@ -22,6 +20,7 @@ import 'package:fluffychat/pangea/events/utils/report_message.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; enum SelectMode { @@ -125,12 +124,12 @@ enum MessageActions { } class SelectModeButtons extends StatefulWidget { - final VoidCallback lauchPractice; + final VoidCallback launchPractice; final MessageOverlayController overlayController; final ChatController controller; const SelectModeButtons({ - required this.lauchPractice, + required this.launchPractice, required this.overlayController, required this.controller, super.key, @@ -144,6 +143,9 @@ class SelectModeButtonsState extends State { static const double iconWidth = 36.0; static const double buttonSize = 40.0; + StreamSubscription? _playerStateSub; + StreamSubscription? _audioSub; + static List get textModes => [ SelectMode.audio, SelectMode.translate, @@ -155,33 +157,15 @@ class SelectModeButtonsState extends State { SelectMode.speechTranslation, ]; - bool _isLoadingAudio = false; - PangeaAudioFile? _audioBytes; - File? _audioFile; - String? _audioError; - - StreamSubscription? _onPlayerStateChanged; - StreamSubscription? _onAudioPositionChanged; - - bool _isLoadingTranslation = false; - String? _translationError; - - bool _isLoadingSpeechTranslation = false; - String? _speechTranslationError; - - Completer? _transcriptionCompleter; - MatrixState? matrix; - SelectMode? get _selectedMode => widget.overlayController.selectedMode; - @override void initState() { super.initState(); matrix = Matrix.of(context); - if (messageEvent?.isAudioMessage == true) { - _fetchTranscription(); + if (messageEvent.isAudioMessage == true) { + controller.fetchTranscription(); } } @@ -190,159 +174,83 @@ class SelectModeButtonsState extends State { matrix?.audioPlayer?.dispose(); matrix?.audioPlayer = null; matrix?.voiceMessageEventId.value = null; - - _onPlayerStateChanged?.cancel(); - _onAudioPositionChanged?.cancel(); + _audioSub?.cancel(); + _playerStateSub?.cancel(); super.dispose(); } - PangeaMessageEvent? get messageEvent => + PangeaMessageEvent get messageEvent => widget.overlayController.pangeaMessageEvent; - String? get l1Code => - MatrixState.pangeaController.languageController.userL1?.langCodeShort; - String? get l2Code => - MatrixState.pangeaController.languageController.userL2?.langCodeShort; - - void _clear() { - setState(() { - // Audio errors do not go away when I switch modes and back - // Is there any reason to wipe error records on clear? - _translationError = null; - _speechTranslationError = null; - }); - - widget.overlayController.updateSelectedSpan(null); - widget.overlayController.setShowTranslation(false); - widget.overlayController.setShowSpeechTranslation(false); - } - - Future _updateMode(SelectMode? mode) async { - _clear(); + SelectModeController get controller => + widget.overlayController.selectModeController; + Future updateMode(SelectMode? mode) async { if (mode == null) { matrix?.audioPlayer?.stop(); matrix?.audioPlayer?.seek(null); - widget.overlayController.setSelectMode(mode); + controller.setSelectMode(mode); return; } - final selectedMode = _selectedMode == mode && - (mode != SelectMode.audio || _audioError != null) - ? null - : mode; - widget.overlayController.setSelectMode(selectedMode); + final updatedMode = + controller.selectedMode.value == mode && mode != SelectMode.audio + ? null + : mode; + controller.setSelectMode(updatedMode); - if (selectedMode == SelectMode.audio) { - _playAudio(); + if (updatedMode == SelectMode.audio) { + playAudio(); return; } else { matrix?.audioPlayer?.stop(); matrix?.audioPlayer?.seek(null); } - if (selectedMode == SelectMode.practice) { - widget.lauchPractice(); + if (updatedMode == SelectMode.practice) { + widget.launchPractice(); return; } - if (selectedMode == SelectMode.translate) { - await _fetchTranslation(); - widget.overlayController.setShowTranslation(true); + if (updatedMode == SelectMode.translate) { + await controller.fetchTranslation(); } - if (selectedMode == SelectMode.speechTranslation) { - await _fetchSpeechTranslation(); - widget.overlayController.setShowSpeechTranslation(true); + if (updatedMode == SelectMode.speechTranslation) { + await controller.fetchSpeechTranslation(); } } - Future _fetchAudio() async { - if (!mounted || messageEvent == null) return; - setState(() => _isLoadingAudio = true); - - try { - final String langCode = messageEvent!.messageDisplayLangCode; - final Event? localEvent = messageEvent!.getTextToSpeechLocal( - langCode, - messageEvent!.messageDisplayText, - ); - - if (localEvent != null) { - _audioBytes = await localEvent.getPangeaAudioFile(); - } else { - _audioBytes = await messageEvent!.getMatrixAudioFile( - langCode, - ); - } - - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - - File? file; - file = File('${tempDir.path}/${_audioBytes!.name}'); - await file.writeAsBytes(_audioBytes!.bytes); - _audioFile = file; - } - } catch (e, s) { - _audioError = e.toString(); - ErrorHandler.logError( - e: e, - s: s, - m: 'something wrong getting audio in MessageAudioCardState', - data: { - 'widget.messageEvent.messageDisplayLangCode': - messageEvent?.messageDisplayLangCode, - }, - ); - } finally { - if (mounted) setState(() => _isLoadingAudio = false); - } - } - - Future _playAudio() async { - final playerID = - "${widget.overlayController.pangeaMessageEvent.eventId}_button"; + Future playAudio() async { + final playerID = "${messageEvent.eventId}_button"; if (matrix?.audioPlayer != null && matrix?.voiceMessageEventId.value == playerID) { // If the audio player is already initialized and playing the same message, pause it if (matrix!.audioPlayer!.playerState.playing) { - await matrix?.audioPlayer?.pause(); + await matrix!.audioPlayer!.pause(); return; } // If the audio player is paused, resume it - await matrix?.audioPlayer?.play(); + await matrix!.audioPlayer!.play(); return; } matrix?.audioPlayer?.dispose(); matrix?.audioPlayer = AudioPlayer(); - matrix?.voiceMessageEventId.value = - "${widget.overlayController.pangeaMessageEvent.eventId}_button"; + matrix?.voiceMessageEventId.value = "${messageEvent.eventId}_button"; - _onPlayerStateChanged = - matrix?.audioPlayer?.playerStateStream.listen((state) { - if (state.processingState == ProcessingState.completed) { - _updateMode(null); - } - setState(() {}); - }); + _playerStateSub?.cancel(); + _playerStateSub = + matrix?.audioPlayer?.playerStateStream.listen(_onUpdatePlayerState); - _onAudioPositionChanged ??= - matrix?.audioPlayer?.positionStream.listen((state) { - if (_audioBytes?.tokens != null) { - widget.overlayController.highlightCurrentText( - state.inMilliseconds, - _audioBytes!.tokens!, - ); - } - }); + _audioSub?.cancel(); + _audioSub = matrix?.audioPlayer?.positionStream.listen(_onPlayAudio); try { if (matrix?.audioPlayer != null && matrix!.audioPlayer!.playerState.playing) { - await matrix?.audioPlayer?.pause(); + await matrix!.audioPlayer!.pause(); return; } else if (matrix?.audioPlayer?.position != Duration.zero) { TtsController.stop(); @@ -350,19 +258,21 @@ class SelectModeButtonsState extends State { return; } - if (_audioBytes == null) { - await _fetchAudio(); + if (controller.audioFile == null) { + await controller.fetchAudio(); } - if (_audioBytes == null) return; + if (controller.audioFile == null) return; + final (PangeaAudioFile pangeaAudioFile, File? audioFile) = + controller.audioFile!; - if (_audioFile != null) { - await matrix?.audioPlayer?.setFilePath(_audioFile!.path); + if (audioFile != null) { + await matrix?.audioPlayer?.setFilePath(audioFile.path); } else { await matrix?.audioPlayer?.setAudioSource( BytesAudioSource( - _audioBytes!.bytes, - _audioBytes!.mimeType, + pangeaAudioFile.bytes, + pangeaAudioFile.mimeType, ), ); } @@ -370,193 +280,42 @@ class SelectModeButtonsState extends State { TtsController.stop(); await matrix?.audioPlayer?.play(); } catch (e, s) { - setState(() => _audioError = e.toString()); ErrorHandler.logError( e: e, s: s, m: 'something wrong playing message audio', data: { - 'event': messageEvent?.event.toJson(), + 'event': messageEvent.event.toJson(), }, ); } } - Future _fetchTranslation() async { - if (l1Code == null || - messageEvent == null || - widget.overlayController.translation != null) { - return; - } - - try { - if (mounted) setState(() => _isLoadingTranslation = true); - final rep = await messageEvent!.l1Respresentation(); - widget.overlayController.setTranslation(rep.text); - } catch (e, s) { - _translationError = e.toString(); - ErrorHandler.logError( - e: e, - s: s, - m: 'Error fetching translation', - data: { - 'l1Code': l1Code, - 'messageEvent': messageEvent?.event.toJson(), - }, - ); - } finally { - if (mounted) setState(() => _isLoadingTranslation = false); - } - } - - Future _fetchTranscription() async { - try { - if (_transcriptionCompleter != null) { - // If a transcription is already in progress, wait for it to complete - await _transcriptionCompleter!.future; - return; - } - - _transcriptionCompleter = Completer(); - if (l1Code == null || messageEvent == null) { - _transcriptionCompleter?.completeError( - 'Language code or message event is null', - ); - return; - } - - final resp = await messageEvent!.getSpeechToText( - l1Code!, - l2Code!, - ); - - widget.overlayController.setTranscription(resp!); - _transcriptionCompleter?.complete(resp.transcript.text); - } catch (err, s) { - widget.overlayController.setTranscriptionError( - err.toString(), - ); - _transcriptionCompleter?.completeError(err); - ErrorHandler.logError( - e: err, - s: s, - data: {}, + void _onPlayAudio(Duration duration) { + if (controller.audioFile?.$1.tokens != null) { + widget.overlayController.highlightCurrentText( + duration.inMilliseconds, + controller.audioFile!.$1.tokens!, ); } } - Future _fetchSpeechTranslation() async { - if (messageEvent == null || - l1Code == null || - l2Code == null || - widget.overlayController.speechTranslation != null) { - return; + void _onUpdatePlayerState(PlayerState state) { + if (state.processingState == ProcessingState.completed) { + updateMode(null); } - - try { - setState(() => _isLoadingSpeechTranslation = true); - - if (widget.overlayController.transcription == null) { - await _fetchTranscription(); - if (widget.overlayController.transcription == null) { - throw Exception('Transcription is null'); - } - } - - final translation = await messageEvent!.sttTranslationByLanguageGlobal( - langCode: l1Code!, - l1Code: l1Code!, - l2Code: l2Code!, - ); - if (translation == null) { - throw Exception('Translation is null'); - } - - widget.overlayController.setSpeechTranslation(translation.translation); - } catch (err, s) { - debugPrint("Error fetching speech translation: $err, $s"); - _speechTranslationError = err.toString(); - ErrorHandler.logError( - e: err, - data: {}, - ); - } finally { - if (mounted) setState(() => _isLoadingSpeechTranslation = false); - } - } - - bool get _isError { - switch (_selectedMode) { - case SelectMode.audio: - return _audioError != null; - case SelectMode.translate: - return _translationError != null; - case SelectMode.speechTranslation: - return _speechTranslationError != null; - default: - return false; - } - } - - bool get _isLoading { - switch (_selectedMode) { - case SelectMode.audio: - return _isLoadingAudio; - case SelectMode.translate: - return _isLoadingTranslation; - case SelectMode.speechTranslation: - return _isLoadingSpeechTranslation; - default: - return false; - } - } - - Widget _icon(SelectMode mode) { - if (_isError && mode == _selectedMode) { - return Icon( - Icons.error_outline, - size: 20, - color: Theme.of(context).colorScheme.error, - ); - } - - if (_isLoading && mode == _selectedMode) { - return const Center( - child: SizedBox( - height: 20.0, - width: 20.0, - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - if (mode == SelectMode.audio) { - return Icon( - matrix?.audioPlayer?.playerState.playing == true - ? Icons.pause_outlined - : Icons.volume_up, - size: 20, - ); - } - - return Icon( - mode.icon, - size: 20, - ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isSubscribed = - MatrixState.pangeaController.subscriptionController.isSubscribed; - List modes = widget.overlayController.showLanguageAssistance - ? messageEvent?.isAudioMessage == true - ? audioModes - : textModes - : []; + final List modes = + widget.overlayController.showLanguageAssistance + ? messageEvent.isAudioMessage == true + ? audioModes + : textModes + : []; - if (isSubscribed == false) modes = []; return Material( type: MaterialType.transparency, child: SizedBox( @@ -571,24 +330,42 @@ class SelectModeButtonsState extends State { alignment: Alignment.center, child: Tooltip( message: mode.tooltip(context), - child: PressableButton( - borderRadius: BorderRadius.circular(20), - depressed: mode == _selectedMode, - color: theme.colorScheme.primaryContainer, - onPressed: () => _updateMode(mode), - playSound: true, - colorFactor: - theme.brightness == Brightness.light ? 0.55 : 0.3, - child: AnimatedContainer( - duration: FluffyThemes.animationDuration, - height: buttonSize, - width: buttonSize, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: _icon(mode), + child: ListenableBuilder( + listenable: Listenable.merge( + [ + controller.selectedMode, + controller.modeStateNotifier(mode), + ], ), + builder: (context, _) { + final selectedMode = controller.selectedMode.value; + return PressableButton( + borderRadius: BorderRadius.circular(20), + depressed: mode == selectedMode, + color: theme.colorScheme.primaryContainer, + onPressed: () => updateMode(mode), + playSound: mode != SelectMode.audio, + colorFactor: + theme.brightness == Brightness.light ? 0.55 : 0.3, + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: buttonSize, + width: buttonSize, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: _SelectModeButtonIcon( + mode: mode, + loading: + controller.isLoading && mode == selectedMode, + playing: mode == SelectMode.audio && + matrix?.audioPlayer?.playerState.playing == + true, + ), + ), + ); + }, ), ), ); @@ -596,9 +373,9 @@ class SelectModeButtonsState extends State { return Container( width: 45.0, alignment: Alignment.center, - child: MoreButton( + child: _MoreButton( controller: widget.controller, - messageEvent: widget.overlayController.pangeaMessageEvent, + messageEvent: messageEvent, ), ); } @@ -609,12 +386,45 @@ class SelectModeButtonsState extends State { } } -class MoreButton extends StatelessWidget { +class _SelectModeButtonIcon extends StatelessWidget { + final SelectMode mode; + final bool loading; + final bool playing; + + const _SelectModeButtonIcon({ + required this.mode, + this.loading = false, + this.playing = false, + }); + + @override + Widget build(BuildContext context) { + if (loading) { + return const Center( + child: SizedBox( + height: 20.0, + width: 20.0, + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + if (mode == SelectMode.audio) { + return Icon( + playing ? Icons.pause_outlined : Icons.volume_up, + size: 20, + ); + } + + return Icon(mode.icon, size: 20); + } +} + +class _MoreButton extends StatelessWidget { final ChatController controller; final PangeaMessageEvent? messageEvent; - const MoreButton({ - super.key, + const _MoreButton({ required this.controller, this.messageEvent, }); diff --git a/lib/pangea/toolbar/widgets/select_mode_controller.dart b/lib/pangea/toolbar/widgets/select_mode_controller.dart new file mode 100644 index 000000000..9b49fe8b2 --- /dev/null +++ b/lib/pangea/toolbar/widgets/select_mode_controller.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:fluffychat/pangea/common/utils/async_state.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; +import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SelectModeController { + final PangeaMessageEvent messageEvent; + + SelectModeController( + this.messageEvent, + ); + + ValueNotifier selectedMode = ValueNotifier(null); + + final ValueNotifier> transcriptionState = + ValueNotifier>(const AsyncState.idle()); + + final ValueNotifier> translationState = + ValueNotifier>(const AsyncState.idle()); + + final ValueNotifier> speechTranslationState = + ValueNotifier>(const AsyncState.idle()); + + final ValueNotifier> audioState = + ValueNotifier>( + const AsyncState.idle(), + ); + + final StreamController contentChangedStream = StreamController.broadcast(); + + bool _disposed = false; + + bool get showingExtraContent => + (selectedMode.value == SelectMode.translate && + translationState.value is AsyncLoaded) || + (selectedMode.value == SelectMode.speechTranslation && + speechTranslationState.value is AsyncLoaded) || + transcriptionState.value is AsyncLoaded || + transcriptionState.value is AsyncError; + + String? get l1Code => + MatrixState.pangeaController.languageController.userL1?.langCodeShort; + String? get l2Code => + MatrixState.pangeaController.languageController.userL2?.langCodeShort; + + (PangeaAudioFile, File?)? get audioFile => audioState.value is AsyncLoaded + ? (audioState.value as AsyncLoaded<(PangeaAudioFile, File?)>).value + : null; + + ValueNotifier? modeStateNotifier(SelectMode mode) { + switch (mode) { + case SelectMode.audio: + return audioState; + case SelectMode.translate: + return translationState; + case SelectMode.speechTranslation: + return speechTranslationState; + default: + return null; + } + } + + ValueNotifier? get currentModeStateNotifier { + final mode = selectedMode.value; + if (mode == null) return null; + return modeStateNotifier(mode); + } + + void dispose() { + selectedMode.dispose(); + transcriptionState.dispose(); + translationState.dispose(); + speechTranslationState.dispose(); + audioState.dispose(); + contentChangedStream.close(); + _disposed = true; + } + + void setSelectMode(SelectMode? mode) { + if (selectedMode.value == mode) return; + selectedMode.value = mode; + } + + Future fetchAudio() async { + audioState.value = const AsyncState.loading(); + try { + final String langCode = messageEvent.messageDisplayLangCode; + final Event? localEvent = messageEvent.getTextToSpeechLocal( + langCode, + messageEvent.messageDisplayText, + ); + + PangeaAudioFile? audioBytes; + if (localEvent != null) { + audioBytes = await localEvent.getPangeaAudioFile(); + } else { + audioBytes = await messageEvent.getMatrixAudioFile( + langCode, + ); + } + if (_disposed) return; + if (audioBytes == null) { + throw Exception('Audio bytes are null'); + } + + File? audioFile; + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + + File? file; + file = File('${tempDir.path}/${audioBytes.name}'); + await file.writeAsBytes(audioBytes.bytes); + audioFile = file; + } + + audioState.value = AsyncState.loaded((audioBytes, audioFile)); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + m: 'something wrong getting audio in MessageAudioCardState', + data: { + 'widget.messageEvent.messageDisplayLangCode': + messageEvent.messageDisplayLangCode, + }, + ); + if (_disposed) return; + audioState.value = AsyncState.error(e); + } + } + + Future fetchTranslation() async { + if (l1Code == null || + translationState.value is AsyncLoading || + translationState.value is AsyncLoaded) { + return; + } + + try { + translationState.value = const AsyncState.loading(); + final rep = await messageEvent.l1Respresentation(); + if (_disposed) return; + translationState.value = AsyncState.loaded(rep.text); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + m: 'Error fetching translation', + data: { + 'l1Code': l1Code, + 'messageEvent': messageEvent.event.toJson(), + }, + ); + if (_disposed) return; + translationState.value = AsyncState.error(e); + } + } + + Future fetchTranscription() async { + try { + if (transcriptionState.value is AsyncLoading || + transcriptionState.value is AsyncLoaded) { + // If a transcription is already in progress or finished, don't fetch again + return; + } + + if (l1Code == null || l2Code == null) { + transcriptionState.value = const AsyncState.error( + 'Language code or message event is null', + ); + return; + } + + final resp = await messageEvent.getSpeechToText( + l1Code!, + l2Code!, + ); + + if (_disposed) return; + if (resp == null) { + transcriptionState.value = const AsyncState.error( + 'Transcription response is null', + ); + return; + } + transcriptionState.value = AsyncState.loaded(resp); + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: {}, + ); + if (_disposed) return; + transcriptionState.value = AsyncState.error(err); + } + } + + Future fetchSpeechTranslation() async { + if (l1Code == null || + l2Code == null || + speechTranslationState.value is AsyncLoading || + speechTranslationState.value is AsyncLoaded) { + return; + } + + if (transcriptionState.value is AsyncError) { + speechTranslationState.value = AsyncState.error( + (transcriptionState.value as AsyncError).error, + ); + return; + } + + try { + speechTranslationState.value = const AsyncState.loading(); + + if (transcriptionState.value is AsyncIdle || + transcriptionState.value is AsyncLoading) { + await fetchTranscription(); + if (_disposed) return; + if (transcriptionState.value is! AsyncLoaded) { + throw Exception('Transcription is null'); + } + } + + final translation = await messageEvent.sttTranslationByLanguageGlobal( + langCode: l1Code!, + l1Code: l1Code!, + l2Code: l2Code!, + ); + if (translation == null) { + throw Exception('Translation is null'); + } + + if (_disposed) return; + speechTranslationState.value = AsyncState.loaded(translation.translation); + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: {}, + ); + if (_disposed) return; + speechTranslationState.value = AsyncState.error(err); + } + } + + bool get isError { + switch (selectedMode.value) { + case SelectMode.audio: + return audioState.value is AsyncError; + case SelectMode.translate: + return translationState.value is AsyncError; + case SelectMode.speechTranslation: + return speechTranslationState.value is AsyncError; + default: + return false; + } + } + + bool get isLoading { + switch (selectedMode.value) { + case SelectMode.audio: + return audioState.value is AsyncLoading; + case SelectMode.translate: + return translationState.value is AsyncLoading; + case SelectMode.speechTranslation: + return speechTranslationState.value is AsyncLoading; + default: + return false; + } + } +} diff --git a/lib/pangea/toolbar/widgets/word_card_switcher.dart b/lib/pangea/toolbar/widgets/word_card_switcher.dart index ce83e0ea3..1050d6c8f 100644 --- a/lib/pangea/toolbar/widgets/word_card_switcher.dart +++ b/lib/pangea/toolbar/widgets/word_card_switcher.dart @@ -11,12 +11,15 @@ class WordCardSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - return AnimatedSize( - alignment: - controller.ownMessage ? Alignment.bottomRight : Alignment.bottomLeft, - duration: FluffyThemes.animationDuration, - child: - controller.widget.overlayController.selectedMode == SelectMode.emoji + return ValueListenableBuilder( + valueListenable: controller.widget.overlayController.selectedMode, + builder: (context, mode, __) { + return AnimatedSize( + alignment: controller.ownMessage + ? Alignment.bottomRight + : Alignment.bottomLeft, + duration: FluffyThemes.animationDuration, + child: mode == SelectMode.emoji ? const SizedBox() : controller.widget.overlayController.selectedToken != null ? ReadingAssistanceContent( @@ -25,6 +28,8 @@ class WordCardSwitcher extends StatelessWidget { : MessageReactionPicker( chatController: controller.widget.chatController, ), + ); + }, ); } }