580 lines
17 KiB
Dart
580 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
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:fluffychat/config/app_config.dart';
|
|
import 'package:fluffychat/config/themes.dart';
|
|
import 'package:fluffychat/l10n/l10n.dart';
|
|
import 'package:fluffychat/pages/chat/chat.dart';
|
|
import 'package:fluffychat/pages/chat/events/audio_player.dart';
|
|
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
|
import 'package:fluffychat/pangea/common/widgets/pressable_button.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/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 {
|
|
audio(Icons.volume_up),
|
|
translate(Icons.translate),
|
|
practice(Symbols.fitness_center),
|
|
emoji(Icons.visibility_outlined),
|
|
speechTranslation(Icons.translate);
|
|
|
|
final IconData icon;
|
|
const SelectMode(this.icon);
|
|
|
|
String tooltip(BuildContext context) {
|
|
final l10n = L10n.of(context);
|
|
switch (this) {
|
|
case SelectMode.audio:
|
|
return l10n.playAudio;
|
|
case SelectMode.translate:
|
|
case SelectMode.speechTranslation:
|
|
return l10n.translationTooltip;
|
|
case SelectMode.practice:
|
|
return l10n.practice;
|
|
case SelectMode.emoji:
|
|
return l10n.emojiView;
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MessageActions {
|
|
reply,
|
|
forward,
|
|
edit,
|
|
delete,
|
|
copy,
|
|
download,
|
|
pin,
|
|
unpin,
|
|
report,
|
|
info,
|
|
deleteOnError,
|
|
sendAgain;
|
|
|
|
IconData get icon {
|
|
switch (this) {
|
|
case MessageActions.reply:
|
|
return Icons.reply_all;
|
|
case MessageActions.forward:
|
|
return Symbols.forward;
|
|
case MessageActions.edit:
|
|
return Symbols.edit;
|
|
case MessageActions.delete:
|
|
return Symbols.delete;
|
|
case MessageActions.copy:
|
|
return Icons.copy_outlined;
|
|
case MessageActions.download:
|
|
return Symbols.download;
|
|
case MessageActions.pin:
|
|
return Icons.push_pin;
|
|
case MessageActions.unpin:
|
|
return Icons.push_pin_outlined;
|
|
case MessageActions.report:
|
|
return Icons.shield_outlined;
|
|
case MessageActions.info:
|
|
return Icons.info_outlined;
|
|
case MessageActions.deleteOnError:
|
|
return Icons.delete;
|
|
case MessageActions.sendAgain:
|
|
return Icons.send_outlined;
|
|
}
|
|
}
|
|
|
|
String tooltip(BuildContext context) {
|
|
final l10n = L10n.of(context);
|
|
switch (this) {
|
|
case MessageActions.reply:
|
|
return l10n.reply;
|
|
case MessageActions.forward:
|
|
return l10n.forward;
|
|
case MessageActions.edit:
|
|
return l10n.edit;
|
|
case MessageActions.delete:
|
|
return l10n.redactMessage;
|
|
case MessageActions.copy:
|
|
return l10n.copy;
|
|
case MessageActions.download:
|
|
return l10n.download;
|
|
case MessageActions.pin:
|
|
return l10n.pinMessage;
|
|
case MessageActions.unpin:
|
|
return l10n.unpin;
|
|
case MessageActions.report:
|
|
return l10n.reportMessage;
|
|
case MessageActions.info:
|
|
return l10n.messageInfo;
|
|
case MessageActions.deleteOnError:
|
|
return l10n.delete;
|
|
case MessageActions.sendAgain:
|
|
return l10n.tryToSendAgain;
|
|
}
|
|
}
|
|
}
|
|
|
|
class SelectModeButtons extends StatefulWidget {
|
|
final VoidCallback launchPractice;
|
|
final MessageOverlayController overlayController;
|
|
final ChatController controller;
|
|
|
|
const SelectModeButtons({
|
|
required this.launchPractice,
|
|
required this.overlayController,
|
|
required this.controller,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
State<SelectModeButtons> createState() => SelectModeButtonsState();
|
|
}
|
|
|
|
class SelectModeButtonsState extends State<SelectModeButtons> {
|
|
static const double iconWidth = 36.0;
|
|
static const double buttonSize = 40.0;
|
|
|
|
StreamSubscription? _playerStateSub;
|
|
StreamSubscription? _audioSub;
|
|
|
|
MatrixState? matrix;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
matrix = Matrix.of(context);
|
|
if (messageEvent.isAudioMessage == true) {
|
|
controller.fetchTranscription();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
matrix?.audioPlayer?.dispose();
|
|
matrix?.audioPlayer = null;
|
|
matrix?.voiceMessageEventId.value = null;
|
|
_audioSub?.cancel();
|
|
_playerStateSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
PangeaMessageEvent get messageEvent =>
|
|
widget.overlayController.pangeaMessageEvent;
|
|
|
|
SelectModeController get controller =>
|
|
widget.overlayController.selectModeController;
|
|
|
|
Future<void> updateMode(SelectMode? mode) async {
|
|
if (mode == null) {
|
|
matrix?.audioPlayer?.stop();
|
|
matrix?.audioPlayer?.seek(null);
|
|
controller.setSelectMode(mode);
|
|
return;
|
|
}
|
|
|
|
final updatedMode =
|
|
controller.selectedMode.value == mode && mode != SelectMode.audio
|
|
? null
|
|
: mode;
|
|
controller.setSelectMode(updatedMode);
|
|
|
|
if (updatedMode == SelectMode.audio) {
|
|
playAudio();
|
|
return;
|
|
} else {
|
|
matrix?.audioPlayer?.stop();
|
|
matrix?.audioPlayer?.seek(null);
|
|
}
|
|
|
|
if (updatedMode == SelectMode.practice) {
|
|
widget.launchPractice();
|
|
return;
|
|
}
|
|
|
|
if (updatedMode == SelectMode.translate) {
|
|
await controller.fetchTranslation();
|
|
}
|
|
|
|
if (updatedMode == SelectMode.speechTranslation) {
|
|
await controller.fetchSpeechTranslation();
|
|
}
|
|
}
|
|
|
|
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();
|
|
return;
|
|
}
|
|
// If the audio player is paused, resume it
|
|
await matrix!.audioPlayer!.play();
|
|
return;
|
|
}
|
|
|
|
matrix?.audioPlayer?.dispose();
|
|
matrix?.audioPlayer = AudioPlayer();
|
|
matrix?.voiceMessageEventId.value = "${messageEvent.eventId}_button";
|
|
|
|
_playerStateSub?.cancel();
|
|
_playerStateSub =
|
|
matrix?.audioPlayer?.playerStateStream.listen(_onUpdatePlayerState);
|
|
|
|
_audioSub?.cancel();
|
|
_audioSub = matrix?.audioPlayer?.positionStream.listen(_onPlayAudio);
|
|
|
|
try {
|
|
if (matrix?.audioPlayer != null &&
|
|
matrix!.audioPlayer!.playerState.playing) {
|
|
await matrix!.audioPlayer!.pause();
|
|
return;
|
|
} else if (matrix?.audioPlayer?.position != Duration.zero) {
|
|
TtsController.stop();
|
|
await matrix?.audioPlayer?.play();
|
|
return;
|
|
}
|
|
|
|
if (controller.audioFile == null) {
|
|
await controller.fetchAudio();
|
|
}
|
|
|
|
if (controller.audioFile == null) return;
|
|
final (PangeaAudioFile pangeaAudioFile, File? audioFile) =
|
|
controller.audioFile!;
|
|
|
|
if (audioFile != null) {
|
|
await matrix?.audioPlayer?.setFilePath(audioFile.path);
|
|
} else {
|
|
await matrix?.audioPlayer?.setAudioSource(
|
|
BytesAudioSource(
|
|
pangeaAudioFile.bytes,
|
|
pangeaAudioFile.mimeType,
|
|
),
|
|
);
|
|
}
|
|
|
|
TtsController.stop();
|
|
await matrix?.audioPlayer?.play();
|
|
} catch (e, s) {
|
|
ErrorHandler.logError(
|
|
e: e,
|
|
s: s,
|
|
m: 'something wrong playing message audio',
|
|
data: {
|
|
'event': messageEvent.event.toJson(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _onPlayAudio(Duration duration) {
|
|
if (controller.audioFile?.$1.tokens != null) {
|
|
widget.overlayController.highlightCurrentText(
|
|
duration.inMilliseconds,
|
|
controller.audioFile!.$1.tokens!,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _onUpdatePlayerState(PlayerState state) {
|
|
if (state.processingState == ProcessingState.completed) {
|
|
updateMode(null);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final modes = controller.readingAssistanceModes;
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: SizedBox(
|
|
height: AppConfig.toolbarMenuHeight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: List.generate(modes.length + 1, (index) {
|
|
if (index < modes.length) {
|
|
final mode = modes[index];
|
|
return Container(
|
|
width: 45.0,
|
|
alignment: Alignment.center,
|
|
child: Tooltip(
|
|
message: mode.tooltip(context),
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return Container(
|
|
width: 45.0,
|
|
alignment: Alignment.center,
|
|
child: _MoreButton(
|
|
controller: widget.controller,
|
|
messageEvent: messageEvent,
|
|
),
|
|
);
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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({
|
|
required this.controller,
|
|
this.messageEvent,
|
|
});
|
|
|
|
bool _messageActionEnabled(MessageActions action) {
|
|
if (messageEvent == null) return false;
|
|
if (controller.selectedEvents.isEmpty) return false;
|
|
final events = controller.selectedEvents;
|
|
|
|
if (events.any((e) => !e.status.isSent)) {
|
|
if (action == MessageActions.sendAgain) {
|
|
return true;
|
|
}
|
|
|
|
if (events.every((e) => e.status.isError) &&
|
|
action == MessageActions.deleteOnError) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
final isPinned = events.length == 1 &&
|
|
controller.room.pinnedEventIds.contains(events.first.eventId);
|
|
|
|
switch (action) {
|
|
case MessageActions.reply:
|
|
return events.length == 1 && controller.room.canSendDefaultMessages;
|
|
case MessageActions.edit:
|
|
return controller.canEditSelectedEvents &&
|
|
!events.first.isActivityMessage &&
|
|
events.single.messageType == MessageTypes.Text;
|
|
case MessageActions.delete:
|
|
return controller.canRedactSelectedEvents;
|
|
case MessageActions.copy:
|
|
return events.length == 1 &&
|
|
events.single.messageType == MessageTypes.Text;
|
|
case MessageActions.download:
|
|
return controller.canSaveSelectedEvent;
|
|
case MessageActions.pin:
|
|
return controller.canPinSelectedEvents && !isPinned;
|
|
case MessageActions.unpin:
|
|
return controller.canPinSelectedEvents && isPinned;
|
|
case MessageActions.forward:
|
|
case MessageActions.report:
|
|
case MessageActions.info:
|
|
return events.length == 1;
|
|
case MessageActions.deleteOnError:
|
|
case MessageActions.sendAgain:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _showMenu(BuildContext context) async {
|
|
final RenderBox button = context.findRenderObject() as RenderBox;
|
|
final RenderBox overlay = Overlay.of(context, rootOverlay: true)
|
|
.context
|
|
.findRenderObject() as RenderBox;
|
|
|
|
final Offset offset = button.localToGlobal(Offset.zero, ancestor: overlay);
|
|
|
|
final RelativeRect position = RelativeRect.fromRect(
|
|
Rect.fromPoints(
|
|
offset,
|
|
offset + button.size.bottomRight(Offset.zero),
|
|
),
|
|
Offset.zero & overlay.size,
|
|
);
|
|
|
|
final action = await showMenu<MessageActions>(
|
|
useRootNavigator: true,
|
|
context: context,
|
|
position: position,
|
|
items: MessageActions.values
|
|
.where(_messageActionEnabled)
|
|
.map(
|
|
(action) => PopupMenuItem<MessageActions>(
|
|
value: action,
|
|
child: Row(
|
|
children: [
|
|
Icon(action.icon),
|
|
const SizedBox(width: 8.0),
|
|
Text(action.tooltip(context)),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
|
|
if (action == null) return;
|
|
_onActionPressed(action, context);
|
|
}
|
|
|
|
void _onActionPressed(
|
|
MessageActions action,
|
|
BuildContext context,
|
|
) {
|
|
switch (action) {
|
|
case MessageActions.reply:
|
|
controller.replyAction();
|
|
break;
|
|
case MessageActions.forward:
|
|
controller.forwardEventsAction();
|
|
break;
|
|
case MessageActions.edit:
|
|
controller.editSelectedEventAction();
|
|
break;
|
|
case MessageActions.delete:
|
|
controller.redactEventsAction();
|
|
break;
|
|
case MessageActions.copy:
|
|
controller.copyEventsAction();
|
|
break;
|
|
case MessageActions.download:
|
|
controller.saveSelectedEvent(context);
|
|
break;
|
|
case MessageActions.pin:
|
|
case MessageActions.unpin:
|
|
controller.pinEvent();
|
|
break;
|
|
case MessageActions.report:
|
|
final event = controller.selectedEvents.first;
|
|
controller.clearSelectedEvents();
|
|
reportEvent(
|
|
event,
|
|
controller,
|
|
controller.context,
|
|
);
|
|
break;
|
|
case MessageActions.info:
|
|
controller.showEventInfo();
|
|
break;
|
|
case MessageActions.deleteOnError:
|
|
controller.deleteErrorEventsAction();
|
|
break;
|
|
case MessageActions.sendAgain:
|
|
controller.sendAgainAction();
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Tooltip(
|
|
message: L10n.of(context).more,
|
|
child: PressableButton(
|
|
borderRadius: BorderRadius.circular(20),
|
|
color: theme.colorScheme.primaryContainer,
|
|
onPressed: () => _showMenu(context),
|
|
playSound: true,
|
|
colorFactor: theme.brightness == Brightness.light ? 0.55 : 0.3,
|
|
child: AnimatedContainer(
|
|
duration: FluffyThemes.animationDuration,
|
|
height: 40.0,
|
|
width: 40.0,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primaryContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.more_horiz,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|