Merge pull request #4663 from pangeachat/4528-toolbar-unsubsribed-message-changes

refactor: update toolbar selelction mode with value notifiers, show u…
This commit is contained in:
ggurdin 2025-11-17 13:00:47 -05:00 committed by GitHub
commit 13efb09d04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 917 additions and 725 deletions

View file

@ -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"
}

View file

@ -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(

View file

@ -0,0 +1,55 @@
/// A generic sealed class that represents the state of an asynchronous operation.
sealed class AsyncState<T> {
/// Base constructor for all asynchronous state variants.
const AsyncState();
/// Represents an idle state before any asynchronous work has begun.
const factory AsyncState.idle() = AsyncIdle<T>;
/// Represents an in-progress loading state.
const factory AsyncState.loading() = AsyncLoading<T>;
/// Represents a completed asynchronous operation with a successful [value].
const factory AsyncState.loaded(T value) = AsyncLoaded<T>;
/// Represents a failed asynchronous operation with an [error].
const factory AsyncState.error(Object error) = AsyncError<T>;
}
/// 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<T> extends AsyncState<T> {
/// 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<T> extends AsyncState<T> {
/// 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<T> extends AsyncState<T> {
/// 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<T> extends AsyncState<T> {
/// The error produced during the asynchronous operation.
final Object error;
/// Creates an error [AsyncState] with an [error].
const AsyncError(this.error);
}

View file

@ -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<SelectMode?> 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<TokenEmojiButton>
with TickerProviderStateMixin {
final double buttonSize = 20.0;
SelectMode? _prevMode;
AnimationController? _controller;
Animation<double>? _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<TokenEmojiButton>
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;
}
}

View file

@ -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,
),
),
],
),
),
);
},
);
}
}

View file

@ -187,8 +187,6 @@ class PracticeSelection {
activityTokens.add(t);
}
debugPrint("TOKENS: ${activityTokens.map((e) => e.text.content).toList()}");
return [
PracticeTarget(
activityType: activityType,

View file

@ -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<MessageSelectionOverlay>
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<SelectMode?> get selectedMode =>
selectModeController.selectedMode;
/////////////////////////////////////
/// Lifecycle
@ -114,6 +105,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
@override
void initState() {
super.initState();
selectModeController = SelectModeController(pangeaMessageEvent);
initializeTokensAndMode();
WidgetsBinding.instance.addPostFrameCallback(
(_) => widget.chatController.setSelectedEvent(event),
@ -125,7 +117,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
WidgetsBinding.instance.addPostFrameCallback(
(_) => widget.chatController.clearSelectedEvents(),
);
contentChangedStream.close();
selectModeController.dispose();
super.dispose();
}
@ -257,13 +249,18 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// 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<MessageSelectionOverlay>
?.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<MessageSelectionOverlay>
);
}
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,
) {

View file

@ -100,7 +100,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
).listen((_) => setState(() {}));
_contentChangedSubscription = widget
.overlayController.contentChangedStream.stream
.overlayController.selectModeController.contentChangedStream.stream
.listen(_onContentSizeChanged);
}
@ -370,7 +370,12 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
}
void setReadingAssistanceMode(ReadingAssistanceMode mode) {
void launchPractice(ReadingAssistanceMode mode) {
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
false) {
return;
}
if (mounted) {
setState(() => readingAssistanceMode = mode);
}

View file

@ -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,
),
),

View file

@ -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<String> 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();
}
},
),
),
);
}
}

View file

@ -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<SelectModeButtons> {
static const double iconWidth = 36.0;
static const double buttonSize = 40.0;
StreamSubscription? _playerStateSub;
StreamSubscription? _audioSub;
static List<SelectMode> get textModes => [
SelectMode.audio,
SelectMode.translate,
@ -155,33 +157,15 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
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<String>? _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<SelectModeButtons> {
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<void> _updateMode(SelectMode? mode) async {
_clear();
SelectModeController get controller =>
widget.overlayController.selectModeController;
Future<void> 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<void> _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<void> _playAudio() async {
final playerID =
"${widget.overlayController.pangeaMessageEvent.eventId}_button";
Future<void> 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<SelectModeButtons> {
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<SelectModeButtons> {
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<void> _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<void> _fetchTranscription() async {
try {
if (_transcriptionCompleter != null) {
// If a transcription is already in progress, wait for it to complete
await _transcriptionCompleter!.future;
return;
}
_transcriptionCompleter = Completer<String>();
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<void> _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<SelectMode> modes = widget.overlayController.showLanguageAssistance
? messageEvent?.isAudioMessage == true
? audioModes
: textModes
: [];
final List<SelectMode> 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<SelectModeButtons> {
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<SelectModeButtons> {
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<SelectModeButtons> {
}
}
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,
});

View file

@ -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<SelectMode?> selectedMode = ValueNotifier<SelectMode?>(null);
final ValueNotifier<AsyncState<SpeechToTextModel>> transcriptionState =
ValueNotifier<AsyncState<SpeechToTextModel>>(const AsyncState.idle());
final ValueNotifier<AsyncState<String>> translationState =
ValueNotifier<AsyncState<String>>(const AsyncState.idle());
final ValueNotifier<AsyncState<String>> speechTranslationState =
ValueNotifier<AsyncState<String>>(const AsyncState.idle());
final ValueNotifier<AsyncState<(PangeaAudioFile, File?)>> audioState =
ValueNotifier<AsyncState<(PangeaAudioFile, File?)>>(
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<AsyncState>? modeStateNotifier(SelectMode mode) {
switch (mode) {
case SelectMode.audio:
return audioState;
case SelectMode.translate:
return translationState;
case SelectMode.speechTranslation:
return speechTranslationState;
default:
return null;
}
}
ValueNotifier<AsyncState>? 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<void> 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<void> 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<void> 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<void> 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;
}
}
}

View file

@ -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,
),
);
},
);
}
}