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:
commit
13efb09d04
13 changed files with 917 additions and 725 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
55
lib/pangea/common/utils/async_state.dart
Normal file
55
lib/pangea/common/utils/async_state.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,8 +187,6 @@ class PracticeSelection {
|
|||
activityTokens.add(t);
|
||||
}
|
||||
|
||||
debugPrint("TOKENS: ${activityTokens.map((e) => e.text.content).toList()}");
|
||||
|
||||
return [
|
||||
PracticeTarget(
|
||||
activityType: activityType,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
284
lib/pangea/toolbar/widgets/select_mode_controller.dart
Normal file
284
lib/pangea/toolbar/widgets/select_mode_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue