You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
545 lines
17 KiB
Dart
545 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer';
|
|
|
|
import 'package:fluffychat/config/app_config.dart';
|
|
import 'package:fluffychat/config/setting_keys.dart';
|
|
import 'package:fluffychat/config/themes.dart';
|
|
import 'package:fluffychat/pages/chat/chat.dart';
|
|
import 'package:fluffychat/pages/chat/events/message_reactions.dart';
|
|
import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart';
|
|
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
|
|
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
|
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
|
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
|
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
|
|
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
|
|
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
|
|
import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
|
|
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
|
|
import 'package:fluffychat/widgets/avatar.dart';
|
|
import 'package:fluffychat/widgets/matrix.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
class MessageSelectionOverlay extends StatefulWidget {
|
|
final ChatController chatController;
|
|
late final Event _event;
|
|
late final Event? _nextEvent;
|
|
late final Event? _prevEvent;
|
|
late final PangeaMessageEvent _pangeaMessageEvent;
|
|
|
|
MessageSelectionOverlay({
|
|
required this.chatController,
|
|
required Event event,
|
|
required PangeaMessageEvent pangeaMessageEvent,
|
|
required Event? nextEvent,
|
|
required Event? prevEvent,
|
|
super.key,
|
|
}) {
|
|
_pangeaMessageEvent = pangeaMessageEvent;
|
|
_nextEvent = nextEvent;
|
|
_prevEvent = prevEvent;
|
|
_event = event;
|
|
}
|
|
|
|
@override
|
|
MessageOverlayController createState() => MessageOverlayController();
|
|
}
|
|
|
|
class MessageOverlayController extends State<MessageSelectionOverlay>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
StreamSubscription? _reactionSubscription;
|
|
Animation<double>? _overlayPositionAnimation;
|
|
|
|
MessageMode toolbarMode = MessageMode.translation;
|
|
PangeaTokenText? _selectedSpan;
|
|
|
|
/// The number of activities that need to be completed before the toolbar is unlocked
|
|
/// If we don't have any good activities for them, we'll decrease this number
|
|
static const int neededActivities = 3;
|
|
|
|
int activitiesLeftToComplete = neededActivities;
|
|
|
|
PangeaMessageEvent get pangeaMessageEvent => widget._pangeaMessageEvent;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: FluffyThemes.animationDuration,
|
|
);
|
|
|
|
activitiesLeftToComplete = activitiesLeftToComplete -
|
|
widget._pangeaMessageEvent.numberOfActivitiesCompleted;
|
|
|
|
_reactionSubscription =
|
|
widget.chatController.room.client.onSync.stream.where(
|
|
(update) {
|
|
// check if this sync update has a reaction event or a
|
|
// redaction (of a reaction event). If so, rebuild the overlay
|
|
final room = widget.chatController.room;
|
|
final timelineEvents = update.rooms?.join?[room.id]?.timeline?.events;
|
|
if (timelineEvents == null) return false;
|
|
|
|
final eventID = widget._pangeaMessageEvent.event.eventId;
|
|
return timelineEvents.any(
|
|
(e) =>
|
|
e.type == EventTypes.Redaction ||
|
|
(e.type == EventTypes.Reaction &&
|
|
Event.fromMatrixEvent(e, room).relationshipEventId ==
|
|
eventID),
|
|
);
|
|
},
|
|
).listen((_) => setState(() {}));
|
|
|
|
setInitialToolbarMode();
|
|
}
|
|
|
|
/// We need to check if the setState call is safe to call immediately
|
|
/// Kept getting the error: setState() or markNeedsBuild() called during build.
|
|
/// This is a workaround to prevent that error
|
|
@override
|
|
void setState(VoidCallback fn) {
|
|
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle ||
|
|
SchedulerBinding.instance.schedulerPhase ==
|
|
SchedulerPhase.postFrameCallbacks) {
|
|
// It's safe to call setState immediately
|
|
super.setState(fn);
|
|
} else {
|
|
// Defer the setState call to after the current frame
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
super.setState(fn);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
bool get isPracticeComplete => activitiesLeftToComplete <= 0;
|
|
|
|
/// When an activity is completed, we need to update the state
|
|
/// and check if the toolbar should be unlocked
|
|
void onActivityFinish() {
|
|
if (!mounted) return;
|
|
activitiesLeftToComplete -= 1;
|
|
clearSelection();
|
|
setState(() {});
|
|
}
|
|
|
|
/// In some cases, we need to exit the practice flow and let the user
|
|
/// interact with the toolbar without completing activities
|
|
void exitPracticeFlow() {
|
|
clearSelection();
|
|
activitiesLeftToComplete = 0;
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> setInitialToolbarMode() async {
|
|
if (widget._pangeaMessageEvent.isAudioMessage) {
|
|
toolbarMode = MessageMode.speechToText;
|
|
return;
|
|
}
|
|
|
|
if (activitiesLeftToComplete > 0) {
|
|
toolbarMode = MessageMode.practiceActivity;
|
|
return;
|
|
}
|
|
|
|
if (MatrixState.pangeaController.userController.profile.userSettings
|
|
.autoPlayMessages) {
|
|
toolbarMode = MessageMode.textToSpeech;
|
|
return;
|
|
}
|
|
|
|
toolbarMode = MessageMode.translation;
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
updateToolbarMode(MessageMode mode) {
|
|
setState(() {
|
|
toolbarMode = mode;
|
|
});
|
|
}
|
|
|
|
/// The text that the toolbar should target
|
|
/// If there is no selectedSpan, then the whole message is the target
|
|
/// If there is a selectedSpan, then the target is the selected text
|
|
String get targetText {
|
|
if (_selectedSpan == null) {
|
|
return widget._pangeaMessageEvent.messageDisplayText;
|
|
}
|
|
|
|
return widget._pangeaMessageEvent.messageDisplayText.substring(
|
|
_selectedSpan!.offset,
|
|
_selectedSpan!.offset + _selectedSpan!.length,
|
|
);
|
|
}
|
|
|
|
void onClickOverlayMessageToken(
|
|
PangeaToken token,
|
|
) {
|
|
if ([
|
|
MessageMode.practiceActivity,
|
|
// MessageMode.textToSpeech
|
|
].contains(toolbarMode)) {
|
|
return;
|
|
}
|
|
|
|
// if there's no selected span, then select the token
|
|
if (_selectedSpan == null) {
|
|
_selectedSpan = token.text;
|
|
} else {
|
|
// if there is a selected span, then deselect the token if it's the same
|
|
if (isTokenSelected(token)) {
|
|
_selectedSpan = null;
|
|
} else {
|
|
// if there is a selected span but it is not the same, then select the token
|
|
_selectedSpan = token.text;
|
|
}
|
|
}
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
void clearSelection() {
|
|
_selectedSpan = null;
|
|
setState(() {});
|
|
}
|
|
|
|
void setSelectedSpan(PracticeActivityModel activity) {
|
|
final RelevantSpanDisplayDetails? span =
|
|
activity.content.spanDisplayDetails;
|
|
|
|
if (span == null) {
|
|
debugger(when: kDebugMode);
|
|
return;
|
|
}
|
|
|
|
if (span.displayInstructions != ActivityDisplayInstructionsEnum.nothing) {
|
|
_selectedSpan = PangeaTokenText(
|
|
offset: span.offset,
|
|
length: span.length,
|
|
content: widget._pangeaMessageEvent.messageDisplayText
|
|
.substring(span.offset, span.offset + span.length),
|
|
);
|
|
} else {
|
|
_selectedSpan = null;
|
|
}
|
|
|
|
setState(() {});
|
|
}
|
|
|
|
/// Whether the given token is currently selected
|
|
bool isTokenSelected(PangeaToken token) {
|
|
return _selectedSpan?.offset == token.text.offset &&
|
|
_selectedSpan?.length == token.text.length;
|
|
}
|
|
|
|
/// Whether the overlay is currently displaying a selection
|
|
bool get isSelection => _selectedSpan != null;
|
|
|
|
PangeaTokenText? get selectedSpan => _selectedSpan;
|
|
|
|
bool get hasReactions {
|
|
final reactionsEvents = widget._pangeaMessageEvent.event.aggregatedEvents(
|
|
widget.chatController.timeline!,
|
|
RelationshipTypes.reaction,
|
|
);
|
|
return reactionsEvents.where((e) => !e.redacted).isNotEmpty;
|
|
}
|
|
|
|
final double toolbarButtonsHeight = 50;
|
|
double get reactionsHeight => hasReactions ? 28 : 0;
|
|
double get belowMessageHeight => toolbarButtonsHeight + reactionsHeight;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (messageSize == null || messageOffset == null) {
|
|
return;
|
|
}
|
|
|
|
// position the overlay directly over the underlying message
|
|
final headerBottomOffset = screenHeight - headerHeight;
|
|
final footerBottomOffset = footerHeight;
|
|
final currentBottomOffset = screenHeight -
|
|
messageOffset!.dy -
|
|
messageSize!.height -
|
|
belowMessageHeight;
|
|
|
|
final bool hasHeaderOverflow =
|
|
messageOffset!.dy < (AppConfig.toolbarMaxHeight + headerHeight);
|
|
final bool hasFooterOverflow = footerHeight > currentBottomOffset;
|
|
|
|
if (!hasHeaderOverflow && !hasFooterOverflow) return;
|
|
|
|
double scrollOffset = 0;
|
|
double animationEndOffset = 0;
|
|
|
|
final midpoint = (headerBottomOffset + footerBottomOffset) / 2;
|
|
|
|
// if the overlay would have a footer overflow for this message,
|
|
// check if shifting the overlay up could cause a header overflow
|
|
final bottomOffsetDifference = footerHeight - currentBottomOffset;
|
|
final newTopOffset =
|
|
messageOffset!.dy - bottomOffsetDifference - belowMessageHeight;
|
|
final bool upshiftCausesHeaderOverflow = hasFooterOverflow &&
|
|
newTopOffset < (headerHeight + AppConfig.toolbarMaxHeight);
|
|
|
|
if (hasHeaderOverflow || upshiftCausesHeaderOverflow) {
|
|
animationEndOffset = midpoint - messageSize!.height - belowMessageHeight;
|
|
final totalTopOffset =
|
|
animationEndOffset + messageSize!.height + AppConfig.toolbarMaxHeight;
|
|
final remainingSpace = screenHeight - totalTopOffset;
|
|
if (remainingSpace < headerHeight) {
|
|
// the overlay could run over the header, so it needs to be shifted down
|
|
animationEndOffset -= (headerHeight - remainingSpace);
|
|
}
|
|
scrollOffset = animationEndOffset - currentBottomOffset;
|
|
} else if (hasFooterOverflow) {
|
|
scrollOffset = footerHeight - currentBottomOffset;
|
|
animationEndOffset = footerHeight;
|
|
}
|
|
|
|
// If, after ajusting the overlay position, the message still overflows the footer,
|
|
// update the message height to fit the screen. The message is scrollable, so
|
|
// this will make the both the toolbar box and the toolbar buttons visible.
|
|
if (animationEndOffset < footerHeight + belowMessageHeight) {
|
|
final double remainingSpace = screenHeight -
|
|
AppConfig.toolbarMaxHeight -
|
|
headerHeight -
|
|
footerHeight -
|
|
belowMessageHeight;
|
|
|
|
if (remainingSpace < messageSize!.height) {
|
|
adjustedMessageHeight = remainingSpace;
|
|
}
|
|
|
|
animationEndOffset = footerHeight;
|
|
}
|
|
|
|
_overlayPositionAnimation = Tween<double>(
|
|
begin: currentBottomOffset,
|
|
end: animationEndOffset,
|
|
).animate(
|
|
CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: FluffyThemes.animationCurve,
|
|
),
|
|
);
|
|
|
|
widget.chatController.scrollController.animateTo(
|
|
widget.chatController.scrollController.offset - scrollOffset,
|
|
duration: FluffyThemes.animationDuration,
|
|
curve: FluffyThemes.animationCurve,
|
|
);
|
|
_animationController.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
_reactionSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
RenderBox? get messageRenderBox => MatrixState.pAnyState.getRenderBox(
|
|
widget._event.eventId,
|
|
);
|
|
|
|
Size? get messageSize => messageRenderBox?.size;
|
|
Offset? get messageOffset => messageRenderBox?.localToGlobal(Offset.zero);
|
|
double? adjustedMessageHeight;
|
|
|
|
// height of the reply/forward bar + the reaction picker + contextual padding
|
|
double get footerHeight =>
|
|
48 + 56 + (FluffyThemes.isColumnMode(context) ? 16.0 : 8.0);
|
|
|
|
double get headerHeight =>
|
|
(Theme.of(context).appBarTheme.toolbarHeight ?? 56) +
|
|
MediaQuery.of(context).padding.top;
|
|
|
|
double get screenHeight => MediaQuery.of(context).size.height;
|
|
|
|
double get screenWidth => MediaQuery.of(context).size.width;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool showDetails = (Matrix.of(context)
|
|
.store
|
|
.getBool(SettingKeys.displayChatDetailsColumn) ??
|
|
false) &&
|
|
FluffyThemes.isThreeColumnMode(context) &&
|
|
widget.chatController.room.membership == Membership.join;
|
|
|
|
// the default spacing between the side of the screen and the message bubble
|
|
final double messageMargin =
|
|
pangeaMessageEvent.ownMessage ? Avatar.defaultSize + 16 : 8;
|
|
|
|
// the actual spacing between the side of the screen and
|
|
// the message bubble, accounts for wide screen
|
|
double extraChatSpace = FluffyThemes.isColumnMode(context)
|
|
? ((screenWidth -
|
|
(FluffyThemes.columnWidth * 3.5) -
|
|
FluffyThemes.navRailWidth) /
|
|
2) +
|
|
messageMargin
|
|
: messageMargin;
|
|
|
|
if (extraChatSpace < messageMargin) {
|
|
extraChatSpace = messageMargin;
|
|
}
|
|
|
|
final overlayMessage = Container(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: FluffyThemes.columnWidth * 2.5,
|
|
),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: widget._pangeaMessageEvent.ownMessage
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
MessageToolbar(
|
|
pangeaMessageEvent: widget._pangeaMessageEvent,
|
|
overLayController: this,
|
|
),
|
|
SizedBox(
|
|
height: adjustedMessageHeight,
|
|
child: OverlayMessage(
|
|
pangeaMessageEvent,
|
|
immersionMode:
|
|
widget.chatController.choreographer.immersionMode,
|
|
controller: widget.chatController,
|
|
overlayController: this,
|
|
nextEvent: widget._nextEvent,
|
|
prevEvent: widget._prevEvent,
|
|
timeline: widget.chatController.timeline!,
|
|
messageWidth: messageSize!.width,
|
|
messageHeight: messageSize!.height,
|
|
),
|
|
),
|
|
if (hasReactions)
|
|
Padding(
|
|
padding: const EdgeInsets.all(4),
|
|
child: SizedBox(
|
|
height: reactionsHeight - 8,
|
|
child: MessageReactions(
|
|
widget._pangeaMessageEvent.event,
|
|
widget.chatController.timeline!,
|
|
),
|
|
),
|
|
),
|
|
ToolbarButtons(
|
|
overlayController: this,
|
|
width: 250,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
|
|
final columnOffset = FluffyThemes.isColumnMode(context)
|
|
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
|
|
: 0;
|
|
|
|
final double leftPadding = widget._pangeaMessageEvent.ownMessage
|
|
? extraChatSpace
|
|
: messageOffset!.dx - horizontalPadding - columnOffset;
|
|
|
|
final double rightPadding = widget._pangeaMessageEvent.ownMessage
|
|
? screenWidth -
|
|
messageOffset!.dx -
|
|
messageSize!.width -
|
|
horizontalPadding
|
|
: extraChatSpace;
|
|
|
|
final positionedOverlayMessage = _overlayPositionAnimation == null
|
|
? Positioned(
|
|
left: leftPadding,
|
|
right: rightPadding,
|
|
bottom: screenHeight -
|
|
messageOffset!.dy -
|
|
messageSize!.height -
|
|
belowMessageHeight,
|
|
child: overlayMessage,
|
|
)
|
|
: AnimatedBuilder(
|
|
animation: _overlayPositionAnimation!,
|
|
builder: (context, child) {
|
|
return Positioned(
|
|
left: leftPadding,
|
|
right: rightPadding,
|
|
bottom: _overlayPositionAnimation!.value,
|
|
child: overlayMessage,
|
|
);
|
|
},
|
|
);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: horizontalPadding,
|
|
right: horizontalPadding,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
positionedOverlayMessage,
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
OverlayFooter(controller: widget.chatController),
|
|
],
|
|
),
|
|
),
|
|
if (showDetails)
|
|
const SizedBox(
|
|
width: FluffyThemes.columnWidth,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Material(
|
|
child: OverlayHeader(controller: widget.chatController),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MessagePadding extends StatelessWidget {
|
|
const MessagePadding({
|
|
super.key,
|
|
required this.child,
|
|
required this.pangeaMessageEvent,
|
|
});
|
|
|
|
final Widget child;
|
|
final PangeaMessageEvent pangeaMessageEvent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: pangeaMessageEvent.ownMessage ? 0 : Avatar.defaultSize + 16,
|
|
right: pangeaMessageEvent.ownMessage ? 8 : 0,
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|