refactor: move all messaging sending logic into the chore controller, reduce full rebuilds of the chat view

This commit is contained in:
ggurdin 2025-11-10 13:56:12 -05:00
parent e021130bda
commit f681ffa71f
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
24 changed files with 1182 additions and 1215 deletions

View file

@ -1,10 +1,23 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:collection/collection.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:just_audio/just_audio.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
@ -26,14 +39,15 @@ import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/utils/unlocked_morphs_snackbar.dart';
import 'package:fluffychat/pangea/chat/widgets/event_too_large_dialog.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart';
@ -71,24 +85,18 @@ import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/share_scaffold_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:just_audio/just_audio.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/html.dart' as html;
import '../../utils/account_bundles.dart';
import '../../utils/localized_exception_extension.dart';
import 'send_file_dialog.dart';
import 'send_location_dialog.dart';
// #Pangea
class _TimelineUpdateNotifier extends ChangeNotifier {
void notify() {
notifyListeners();
}
}
// Pangea#
class ChatPage extends StatelessWidget {
final String roomId;
final List<ShareItem>? shareItems;
@ -181,6 +189,7 @@ class ChatController extends State<ChatPageWithRoom>
StreamSubscription? _levelSubscription;
StreamSubscription? _analyticsSubscription;
StreamSubscription? _botAudioSubscription;
final timelineUpdateNotifier = _TimelineUpdateNotifier();
// Pangea#
Room get room => sendingClient.getRoomById(roomId) ?? widget.room;
@ -438,12 +447,6 @@ class ChatController extends State<ChatPageWithRoom>
if (!mounted) return;
Future.delayed(const Duration(seconds: 1), () async {
if (!mounted) return;
debugPrint(
"chat.dart l1 ${pangeaController.languageController.userL1?.langCode}",
);
debugPrint(
"chat.dart l2 ${pangeaController.languageController.userL2?.langCode}",
);
if (mounted) {
pangeaController.languageController.showDialogOnEmptyLanguage(
context,
@ -519,9 +522,6 @@ class ChatController extends State<ChatPageWithRoom>
final event = Event.fromMatrixEvent(botAudioEvent, room);
final audioFile = await event.getPangeaAudioFile();
debugPrint(
"audiofile: ${audioFile?.mimeType} ${audioFile?.bytes.length}",
);
if (audioFile == null) return;
if (!kIsWeb) {
@ -545,6 +545,9 @@ class ChatController extends State<ChatPageWithRoom>
// Pangea#
_tryLoadTimeline();
if (kIsWeb) {
// #Pangea
onFocusSub?.cancel();
// Pangea#
onFocusSub = html.window.onFocus.listen((_) => setReadMarker());
}
}
@ -602,7 +605,10 @@ class ChatController extends State<ChatPageWithRoom>
void updateView() {
if (!mounted) return;
setReadMarker();
setState(() {});
// #Pangea
// setState(() {});
if (mounted) timelineUpdateNotifier.notify();
// Pangea#
}
Future<void>? loadTimelineFuture;
@ -611,14 +617,6 @@ class ChatController extends State<ChatPageWithRoom>
void onInsert(int i) {
// setState will be called by updateView() anyway
// #Pangea
// If fake event was sent, don't animate in the next event.
// It makes the replacement of the fake event jumpy.
if (_fakeEventIDs.isNotEmpty) {
animateInEventIndex = null;
return;
}
// Pangea#
animateInEventIndex = i;
}
@ -686,6 +684,7 @@ class ChatController extends State<ChatPageWithRoom>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// #Pangea
super.didChangeAppLifecycleState(state);
// On iOS, if the toolbar is open and the app is closed, then the user goes
// back to do more toolbar activities, the toolbar buttons / selection don't
// update properly. So, when the user closes the app, close the toolbar overlay.
@ -785,6 +784,17 @@ class ChatController extends State<ChatPageWithRoom>
// Pangea#
onFocusSub?.cancel();
//#Pangea
WidgetsBinding.instance.removeObserver(this);
_storeInputTimeoutTimer?.cancel();
_displayChatDetailsColumn.dispose();
timelineUpdateNotifier.dispose();
highlightedRole.dispose();
showInstructions.dispose();
showActivityDropdown.dispose();
hasRainedConfetti.dispose();
typingCoolDown?.cancel();
typingTimeout?.cancel();
scrollController.removeListener(_updateScrollController);
choreographer.dispose();
MatrixState.pAnyState.closeAllOverlays(force: true);
showToolbarStream.close();
@ -826,33 +836,37 @@ class ChatController extends State<ChatPageWithRoom>
// TextEditingController sendController = TextEditingController();
PangeaTextController get sendController => choreographer.textController;
// Pangea#
// #Pangea
// void setSendingClient(Client c) {
// // first cancel typing with the old sending client
// if (currentlyTyping) {
// // no need to have the setting typing to false be blocking
// typingCoolDown?.cancel();
// typingCoolDown = null;
// room.setTyping(false);
// currentlyTyping = false;
// }
// // then cancel the old timeline
// // fixes bug with read reciepts and quick switching
// loadTimelineFuture = _getTimeline(eventContextId: room.fullyRead).onError(
// ErrorReporter(
// context,
// 'Unable to load timeline after changing sending Client',
// ).onErrorCallback,
// );
void setSendingClient(Client c) {
// first cancel typing with the old sending client
if (currentlyTyping) {
// no need to have the setting typing to false be blocking
typingCoolDown?.cancel();
typingCoolDown = null;
room.setTyping(false);
currentlyTyping = false;
}
// then cancel the old timeline
// fixes bug with read reciepts and quick switching
loadTimelineFuture = _getTimeline(eventContextId: room.fullyRead).onError(
ErrorReporter(
context,
'Unable to load timeline after changing sending Client',
).onErrorCallback,
);
// // then set the new sending client
// setState(() => sendingClient = c);
// }
// then set the new sending client
setState(() => sendingClient = c);
}
void setActiveClient(Client c) => setState(() {
Matrix.of(context).setActiveClient(c);
});
// void setActiveClient(Client c) {
// setState(() {
// Matrix.of(context).setActiveClient(c);
// });
// }
// Pangea#
// #Pangea
Event? pangeaEditingEvent;
@ -860,64 +874,38 @@ class ChatController extends State<ChatPageWithRoom>
pangeaEditingEvent = null;
}
final List<String> _fakeEventIDs = [];
bool get obscureText => _fakeEventIDs.isNotEmpty;
/// Add a fake event to the timeline to visually indicate that a message is being sent.
/// Used when tokenizing after message send, specifically because tokenization for some
/// languages takes some time.
String? sendFakeMessage() {
Future<String?> sendFakeMessage(Event? edit, Event? reply) async {
if (sendController.text.trim().isEmpty) return null;
final eventID = room.sendFakeMessage(
text: sendController.text,
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
);
final message = sendController.text;
inputFocus.unfocus();
sendController.setSystemText("", EditTypeEnum.other);
setState(() => _fakeEventIDs.add(eventID));
// wait for the next event to come through before clearing any fake event,
// to make the replacement look smooth
room.client.onTimelineEvent.stream
.firstWhere((event) => event.content[ModelKey.tempEventId] == eventID)
.then(
(_) => clearFakeEvent(eventID),
);
return eventID;
}
void clearFakeEvent(String? eventId) {
if (eventId == null) return;
final inTimeline = timeline != null &&
timeline!.events.any(
(e) => e.eventId == eventId,
);
if (!inTimeline) return;
timeline?.events.removeWhere((e) => e.eventId == eventId);
setState(() {
_fakeEventIDs.remove(eventId);
});
return room.sendFakeMessage(
text: message,
inReplyTo: reply,
editEventId: edit?.eventId,
);
}
// Future<void> send() async {
// Original send function gets the tx id within the matrix lib,
// but for choero, the tx id is generated before the message send.
// Also, adding PangeaMessageData
Future<void> send({
required String message,
PangeaRepresentation? originalSent,
PangeaRepresentation? originalWritten,
PangeaMessageTokens? tokensSent,
PangeaMessageTokens? tokensWritten,
ChoreoRecordModel? choreo,
String? tempEventId,
}) async {
Future<void> send() async {
final message = sendController.text;
final edit = editEvent;
final reply = replyEvent;
editEvent = null;
replyEvent = null;
pendingText = '';
final tempEventId = await sendFakeMessage(edit, reply);
final content = await choreographer.getMessageContent(message);
choreographer.clear();
if (message.trim().isEmpty) return;
// if (sendController.text.trim().isEmpty) return;
// Pangea#
@ -967,15 +955,15 @@ class ChatController extends State<ChatPageWithRoom>
room
.pangeaSendTextEvent(
message,
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
inReplyTo: reply,
editEventId: edit?.eventId,
parseCommands: parseCommands,
originalSent: originalSent,
originalWritten: originalWritten,
tokensSent: tokensSent,
tokensWritten: tokensWritten,
choreo: choreo,
tempEventId: tempEventId,
originalSent: content.originalSent,
originalWritten: content.originalWritten,
tokensSent: content.tokensSent,
tokensWritten: content.tokensWritten,
choreo: content.choreo,
txid: tempEventId,
)
.then(
(String? msgEventId) async {
@ -985,9 +973,9 @@ class ChatController extends State<ChatPageWithRoom>
// stream sends the data for newly sent messages.
_sendMessageAnalytics(
msgEventId,
originalSent: originalSent,
tokensSent: tokensSent,
choreo: choreo,
originalSent: content.originalSent,
tokensSent: content.tokensSent,
choreo: content.choreo,
);
if (previousEdit != null) {
@ -1017,7 +1005,6 @@ class ChatController extends State<ChatPageWithRoom>
}
},
).catchError((err, s) {
clearFakeEvent(tempEventId);
if (err is EventTooLarge) {
showAdaptiveDialog(
context: context,
@ -1036,20 +1023,20 @@ class ChatController extends State<ChatPageWithRoom>
},
);
});
// #Pangea
// sendController.value = TextEditingValue(
// text: pendingText,
// selection: const TextSelection.collapsed(offset: 0),
// );
setState(() {
// #Pangea
// sendController.text = pendingText;
// Pangea#
_inputTextIsEmpty = pendingText.isEmpty;
replyEvent = null;
editEvent = null;
pendingText = '';
});
// setState(() {
// sendController.text = pendingText;
// _inputTextIsEmpty = pendingText.isEmpty;
// replyEvent = null;
// editEvent = null;
// pendingText = '';
// });
// Pangea#
}
void sendFileAction({FileSelectorType type = FileSelectorType.any}) async {
@ -1159,10 +1146,18 @@ class ChatController extends State<ChatPageWithRoom>
name: result.fileName ?? audioFile.path,
);
// #Pangea
final reply = replyEvent;
replyEvent = null;
// Pangea#
await room
.sendFileEvent(
file,
inReplyTo: replyEvent,
// #Pangea
// inReplyTo: replyEvent,
inReplyTo: reply,
// Pangea#
extraContent: {
'info': {
...file.info,
@ -1214,24 +1209,13 @@ class ChatController extends State<ChatPageWithRoom>
// setState(() {
// replyEvent = null;
// });
if (mounted) setState(() => replyEvent = null);
// Pangea#
}
void hideEmojiPicker() {
// #Pangea
clearSelectedEvents();
// Pangea#
setState(() => showEmojiPicker = false);
}
// #Pangea
void hideOverlayEmojiPicker() {
MatrixState.pAnyState.closeOverlay();
setState(() => showEmojiPicker = false);
}
// Pangea
void emojiPickerAction() {
if (showEmojiPicker) {
inputFocus.requestFocus();
@ -1275,13 +1259,13 @@ class ChatController extends State<ChatPageWithRoom>
void copyEventsAction() {
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
setState(() {
showEmojiPicker = false;
// #Pangea
// selectedEvents.clear();
clearSelectedEvents();
// Pangea#
});
// #Pangea
// setState(() {
// showEmojiPicker = false;
// selectedEvents.clear();
// });
clearSelectedEvents();
// Pangea#
}
void reportEventAction() async {
@ -1325,10 +1309,13 @@ class ChatController extends State<ChatPageWithRoom>
),
);
if (result.error != null) return;
setState(() {
showEmojiPicker = false;
selectedEvents.clear();
});
// #Pangea
// setState(() {
// showEmojiPicker = false;
// selectedEvents.clear();
// });
clearSelectedEvents();
// Pangea#
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)),
);
@ -1419,12 +1406,12 @@ class ChatController extends State<ChatPageWithRoom>
);
}
// #Pangea
// setState(() {
// showEmojiPicker = false;
// selectedEvents.clear();
// });
clearSelectedEvents();
// Pangea#
setState(() {
showEmojiPicker = false;
selectedEvents.clear();
});
}
List<Client?> get currentRoomBundle {
@ -1522,17 +1509,20 @@ class ChatController extends State<ChatPageWithRoom>
for (final e in allEditEvents) {
e.sendAgain();
}
setState(() => selectedEvents.clear());
// #Pangea
// setState(() => selectedEvents.clear());
// Pangea#
}
void replyAction({Event? replyTo}) {
setState(() {
replyEvent = replyTo ?? selectedEvents.first;
selectedEvents.clear();
});
// #Pangea
replyEvent = replyTo ?? selectedEvents.first;
clearSelectedEvents();
// Pangea
// setState(() {
// replyEvent = replyTo ?? selectedEvents.first;
// selectedEvents.clear();
// });
// Pangea#
inputFocus.requestFocus();
}
@ -1637,8 +1627,9 @@ class ChatController extends State<ChatPageWithRoom>
// });
void clearSelectedEvents() {
if (!mounted) return;
if (!_isToolbarOpen && selectedEvents.isEmpty) return;
MatrixState.pAnyState.closeAllOverlays();
setState(() {
MatrixState.pAnyState.closeAllOverlays();
selectedEvents.clear();
showEmojiPicker = false;
});
@ -1651,32 +1642,45 @@ class ChatController extends State<ChatPageWithRoom>
});
}
void clearSingleSelectedEvent() {
if (selectedEvents.length <= 1) {
clearSelectedEvents();
}
}
// #Pangea
// void clearSingleSelectedEvent() {
// if (selectedEvents.length <= 1) {
// clearSelectedEvents();
// }
// }
// Pangea#
void editSelectedEventAction() {
final client = currentRoomBundle.firstWhere(
(cl) => selectedEvents.first.senderId == cl!.userID,
orElse: () => null,
);
if (client == null) {
return;
}
setSendingClient(client);
setState(() {
pendingText = sendController.text;
editEvent = selectedEvents.first;
sendController.text =
editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
);
selectedEvents.clear();
});
// #Pangea
// final client = currentRoomBundle.firstWhere(
// (cl) => selectedEvents.first.senderId == cl!.userID,
// orElse: () => null,
// );
// if (client == null) {
// return;
// }
// setSendingClient(client);
// setState(() {
// pendingText = sendController.text;
// editEvent = selectedEvents.first;
// sendController.text =
// editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback(
// MatrixLocals(L10n.of(context)),
// withSenderNamePrefix: false,
// hideReply: true,
// );
// selectedEvents.clear();
// });
pendingText = sendController.text;
editEvent = selectedEvents.first;
sendController.text =
editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
);
clearSelectedEvents();
// Pangea#
inputFocus.requestFocus();
}
@ -1705,35 +1709,24 @@ class ChatController extends State<ChatPageWithRoom>
);
}
void onSelectMessage(Event event) {
// #Pangea
if (choreographer.itController.open.value) {
return;
}
// Pangea#
if (!event.redacted) {
// #Pangea
// if (selectedEvents.contains(event)) {
// setState(
// () => selectedEvents.remove(event),
// );
// }
// If delete first selected event with the selected eventID
final matches = selectedEvents.where((e) => e.eventId == event.eventId);
if (matches.isNotEmpty) {
setState(() => selectedEvents.remove(matches.first));
// Pangea#
} else {
setState(
() => selectedEvents.add(event),
);
}
selectedEvents.sort(
(a, b) => a.originServerTs.compareTo(b.originServerTs),
);
}
}
// #Pangea
// void onSelectMessage(Event event) {
// if (!event.redacted) {
// if (selectedEvents.contains(event)) {
// setState(
// () => selectedEvents.remove(event),
// );
// } else {
// setState(
// () => selectedEvents.add(event),
// );
// }
// selectedEvents.sort(
// (a, b) => a.originServerTs.compareTo(b.originServerTs),
// );
// }
// }
// Pangea#
int? findChildIndexCallback(Key key, Map<String, int> thisEventsKeyMap) {
// this method is called very often. As such, it has to be optimized for speed.
@ -1757,17 +1750,13 @@ class ChatController extends State<ChatPageWithRoom>
// void onInputBarSubmitted(String _) {
Future<void> onInputBarSubmitted() async {
// send();
try {
await choreographer.send();
} on ShowPaywallException {
if (MatrixState.pangeaController.subscriptionController.shouldShowPaywall) {
PaywallCard.show(context, choreographer.inputTransformTargetKey);
return;
} on OpenMatchesException {
onSelectMatch(choreographer.igcController.firstOpenMatch);
return;
}
await onRequestWritingAssistance(autosend: true);
// FocusScope.of(context).requestFocus(inputFocus);
// Pangea#
FocusScope.of(context).requestFocus(inputFocus);
}
void onAddPopupMenuButtonSelected(String choice) {
@ -1809,9 +1798,7 @@ class ChatController extends State<ChatPageWithRoom>
..removeWhere((oldEvent) => oldEvent == eventId);
// #Pangea
if (scrollToEventIdMarker == eventId) {
setState(() {
scrollToEventIdMarker = null;
});
scrollToEventIdMarker = null;
}
// Pangea#
showFutureLoadingDialog(
@ -1854,31 +1841,38 @@ class ChatController extends State<ChatPageWithRoom>
static const Duration _storeInputTimeout = Duration(milliseconds: 500);
void onInputBarChanged(String text) {
if (_inputTextIsEmpty != text.isEmpty) {
setState(() {
_inputTextIsEmpty = text.isEmpty;
});
}
// #Pangea
// if (_inputTextIsEmpty != text.isEmpty) {
// setState(() {
// _inputTextIsEmpty = text.isEmpty;
// });
// }
// if (_inputTextIsEmpty.value != text.isEmpty) {
// _inputTextIsEmpty.value = text.isEmpty;
// }
// Pangea#
_storeInputTimeoutTimer?.cancel();
_storeInputTimeoutTimer = Timer(_storeInputTimeout, () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('draft_$roomId', text);
});
if (text.endsWith(' ') && Matrix.of(context).hasComplexBundles) {
final clients = currentRoomBundle;
for (final client in clients) {
final prefix = client!.sendPrefix;
if ((prefix.isNotEmpty) &&
text.toLowerCase() == '${prefix.toLowerCase()} ') {
setSendingClient(client);
setState(() {
sendController.clear();
});
return;
}
}
}
// #Pangea
// if (text.endsWith(' ') && Matrix.of(context).hasComplexBundles) {
// final clients = currentRoomBundle;
// for (final client in clients) {
// final prefix = client!.sendPrefix;
// if ((prefix.isNotEmpty) &&
// text.toLowerCase() == '${prefix.toLowerCase()} ') {
// setSendingClient(client);
// setState(() {
// sendController.clear();
// });
// return;
// }
// }
// }
// Pangea#
if (AppConfig.sendTypingNotifications) {
typingCoolDown?.cancel();
typingCoolDown = Timer(const Duration(seconds: 2), () {
@ -1900,7 +1894,9 @@ class ChatController extends State<ChatPageWithRoom>
}
}
bool _inputTextIsEmpty = true;
// #Pangea
// bool _inputTextIsEmpty = true;
// Pangea#
bool get isArchived =>
{Membership.leave, Membership.ban}.contains(room.membership);
@ -1985,6 +1981,9 @@ class ChatController extends State<ChatPageWithRoom>
final StreamController<void> stopMediaStream = StreamController.broadcast();
bool get _isToolbarOpen =>
MatrixState.pAnyState.isOverlayOpen(RegExp(r'^message_toolbar_overlay$'));
void showToolbar(
Event event, {
PangeaMessageEvent? pangeaMessageEvent,
@ -2012,31 +2011,14 @@ class ChatController extends State<ChatPageWithRoom>
return;
}
Widget? overlayEntry;
try {
overlayEntry = MessageSelectionOverlay(
chatController: this,
event: event,
timeline: timeline!,
initialSelectedToken: selectedToken,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
} catch (err) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
s: StackTrace.current,
data: {
'roomId': roomId,
'event': event.toJson(),
'selectedToken': selectedToken?.toJson(),
'nextEvent': nextEvent?.toJson(),
'prevEvent': prevEvent?.toJson(),
},
);
return;
}
final overlayEntry = MessageSelectionOverlay(
chatController: this,
event: event,
timeline: timeline!,
initialSelectedToken: selectedToken,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
// you've clicked a message so lets turn this off
InstructionsEnum.clickMessage.setToggledOff(true);
@ -2045,29 +2027,36 @@ class ChatController extends State<ChatPageWithRoom>
if (!kIsWeb) {
HapticFeedback.mediumImpact();
}
stopMediaStream.add(null);
Future.delayed(
Duration(milliseconds: buttonEventID == event.eventId ? 200 : 0), () {
if (_router.state.path != ':roomid') {
// The user has navigated away from the chat,
// so we don't want to show the overlay.
return;
}
if (buttonEventID == event.eventId) {
Future.delayed(const Duration(milliseconds: 200), () {
if (_router.state.path != ':roomid') {
// The user has navigated away from the chat,
// so we don't want to show the overlay.
return;
}
OverlayUtil.showOverlay(
context: context,
child: overlayEntry,
position: OverlayPositionEnum.centered,
onDismiss: clearSelectedEvents,
blurBackground: true,
backgroundColor: Colors.black,
overlayKey: "message_toolbar_overlay",
);
});
} else {
OverlayUtil.showOverlay(
context: context,
child: overlayEntry!,
child: overlayEntry,
position: OverlayPositionEnum.centered,
onDismiss: clearSelectedEvents,
blurBackground: true,
backgroundColor: Colors.black,
overlayKey: "message_toolbar_overlay",
);
// select the message
onSelectMessage(event);
});
}
}
bool get displayChatDetailsColumn {
@ -2196,23 +2185,36 @@ class ChatController extends State<ChatPageWithRoom>
l1 != activityLang;
}
void onSelectMatch(PangeaMatchState? match) {
if (match != null) {
if (match.updatedMatch.isITStart) {
choreographer.openIT(match);
} else {
OverlayUtil.showIGCMatch(
match,
choreographer,
context,
);
}
Future<void> onRequestWritingAssistance({bool autosend = false}) async {
if (shouldShowLanguageMismatchPopup) {
return showLanguageMismatchPopup();
}
await choreographer.requestWritingAssistance();
if (choreographer.assistanceState == AssistanceStateEnum.fetched) {
onSelectMatch(choreographer.igcController.firstOpenMatch);
} else if (autosend) {
await send();
} else {
inputFocus.requestFocus();
}
}
Future<void> showLanguageMismatchPopup() async {
void onSelectMatch(PangeaMatchState? match) {
if (match != null) {
match.updatedMatch.isITStart
? choreographer.openIT(match)
: OverlayUtil.showIGCMatch(
match,
choreographer,
context,
);
return;
}
inputFocus.requestFocus();
}
void showLanguageMismatchPopup() {
if (!shouldShowLanguageMismatchPopup) {
return;
}
@ -2231,10 +2233,9 @@ class ChatController extends State<ChatPageWithRoom>
waitForDataInSync: true,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await choreographer.requestLanguageAssistance();
onSelectMatch(choreographer.igcController.firstOpenMatch);
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => onRequestWritingAssistance(autosend: true),
);
},
),
maxHeight: 325,
@ -2310,25 +2311,29 @@ class ChatController extends State<ChatPageWithRoom>
final ScrollController carouselController = ScrollController();
ActivityRoleModel? highlightedRole;
ValueNotifier<ActivityRoleModel?> highlightedRole = ValueNotifier(null);
void highlightRole(ActivityRoleModel role) {
if (mounted) setState(() => highlightedRole = role);
if (mounted) highlightedRole.value = role;
}
bool showInstructions = false;
ValueNotifier<bool> showInstructions = ValueNotifier(false);
void toggleShowInstructions() {
if (mounted) setState(() => showInstructions = !showInstructions);
if (mounted) {
showInstructions.value = !showInstructions.value;
}
}
bool showActivityDropdown = false;
ValueNotifier<bool> showActivityDropdown = ValueNotifier(false);
void toggleShowDropdown() async {
setState(() => showActivityDropdown = !showActivityDropdown);
if (mounted) {
showActivityDropdown.value = !showActivityDropdown.value;
}
}
bool hasRainedConfetti = false;
ValueNotifier<bool> hasRainedConfetti = ValueNotifier(false);
void setHasRainedConfetti(bool show) {
if (mounted) {
setState(() => hasRainedConfetti = show);
hasRainedConfetti.value = show;
}
}
// Pangea#

View file

@ -7,8 +7,6 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/other_party_can_receive.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../config/themes.dart';
import 'chat.dart';
import 'input_bar.dart';
@ -256,15 +254,17 @@ class ChatInputRow extends StatelessWidget {
onPressed: controller.emojiPickerAction,
),
),
if (Matrix.of(context).isMultiAccount &&
Matrix.of(context).hasComplexBundles &&
Matrix.of(context).currentBundle!.length > 1)
Container(
width: height,
height: height,
alignment: Alignment.center,
child: _ChatAccountPicker(controller),
),
// #Pangea
// if (Matrix.of(context).isMultiAccount &&
// Matrix.of(context).hasComplexBundles &&
// Matrix.of(context).currentBundle!.length > 1)
// Container(
// width: height,
// height: height,
// alignment: Alignment.center,
// child: _ChatAccountPicker(controller),
// ),
// Pangea#
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
@ -300,7 +300,7 @@ class ChatInputRow extends StatelessWidget {
),
onChanged: controller.onInputBarChanged,
// #Pangea
hintText: "",
choreographer: controller.choreographer,
// Pangea#
),
),
@ -325,12 +325,7 @@ class ChatInputRow extends StatelessWidget {
)
: FloatingActionButton.small(
tooltip: L10n.of(context).send,
// #Pangea
// onPressed: controller.send,
onPressed: () => controller.send(
message: controller.sendController.text,
),
// Pangea#
onPressed: controller.send,
elevation: 0,
heroTag: null,
shape: RoundedRectangleBorder(
@ -346,60 +341,62 @@ class ChatInputRow extends StatelessWidget {
}
}
class _ChatAccountPicker extends StatelessWidget {
final ChatController controller;
// #Pangea
// class _ChatAccountPicker extends StatelessWidget {
// final ChatController controller;
const _ChatAccountPicker(this.controller);
// const _ChatAccountPicker(this.controller);
void _popupMenuButtonSelected(String mxid, BuildContext context) {
final client = Matrix.of(context)
.currentBundle!
.firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
if (client == null) {
Logs().w('Attempted to switch to a non-existing client $mxid');
return;
}
controller.setSendingClient(client);
}
// void _popupMenuButtonSelected(String mxid, BuildContext context) {
// final client = Matrix.of(context)
// .currentBundle!
// .firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
// if (client == null) {
// Logs().w('Attempted to switch to a non-existing client $mxid');
// return;
// }
// controller.setSendingClient(client);
// }
@override
Widget build(BuildContext context) {
final clients = controller.currentRoomBundle;
return Padding(
padding: const EdgeInsets.all(8.0),
child: FutureBuilder<Profile>(
future: controller.sendingClient.fetchOwnProfile(),
builder: (context, snapshot) => PopupMenuButton<String>(
useRootNavigator: true,
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
itemBuilder: (BuildContext context) => clients
.map(
(client) => PopupMenuItem<String>(
value: client!.userID,
child: FutureBuilder<Profile>(
future: client.fetchOwnProfile(),
builder: (context, snapshot) => ListTile(
leading: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
client.userID!.localpart,
size: 20,
),
title: Text(snapshot.data?.displayName ?? client.userID!),
contentPadding: const EdgeInsets.all(0),
),
),
),
)
.toList(),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
Matrix.of(context).client.userID!.localpart,
size: 20,
),
),
),
);
}
}
// @override
// Widget build(BuildContext context) {
// final clients = controller.currentRoomBundle;
// return Padding(
// padding: const EdgeInsets.all(8.0),
// child: FutureBuilder<Profile>(
// future: controller.sendingClient.fetchOwnProfile(),
// builder: (context, snapshot) => PopupMenuButton<String>(
// useRootNavigator: true,
// onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
// itemBuilder: (BuildContext context) => clients
// .map(
// (client) => PopupMenuItem<String>(
// value: client!.userID,
// child: FutureBuilder<Profile>(
// future: client.fetchOwnProfile(),
// builder: (context, snapshot) => ListTile(
// leading: Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name: snapshot.data?.displayName ??
// client.userID!.localpart,
// size: 20,
// ),
// title: Text(snapshot.data?.displayName ?? client.userID!),
// contentPadding: const EdgeInsets.all(0),
// ),
// ),
// ),
// )
// .toList(),
// child: Avatar(
// mxContent: snapshot.data?.avatarUrl,
// name: snapshot.data?.displayName ??
// Matrix.of(context).client.userID!.localpart,
// size: 20,
// ),
// ),
// ),
// );
// }
// }
// Pangea#

View file

@ -353,12 +353,19 @@ class ChatView extends StatelessWidget {
children: <Widget>[
Expanded(
child: GestureDetector(
onTap: controller.clearSingleSelectedEvent,
// #Pangea
// onTap: controller.clearSingleSelectedEvent,
// child: ChatEventList(controller: controller),
child: Stack(
children: [
ChatEventList(controller: controller),
ListenableBuilder(
listenable: controller.timelineUpdateNotifier,
builder: (context, _) {
return ChatEventList(
controller: controller,
);
},
),
ChatViewBackground(
controller.choreographer.itController.open,
),
@ -477,11 +484,18 @@ class ChatView extends StatelessWidget {
),
// #Pangea
ActivityStatsMenu(controller),
if (controller.room.activitySummary?.summary != null &&
controller.hasRainedConfetti == false)
StarRainWidget(
showBlast: true,
onFinished: () => controller.setHasRainedConfetti(true),
if (controller.room.activitySummary?.summary != null)
ValueListenableBuilder(
valueListenable: controller.hasRainedConfetti,
builder: (context, hasRained, __) {
return hasRained
? const SizedBox()
: StarRainWidget(
showBlast: true,
onFinished: () =>
controller.setHasRainedConfetti(true),
);
},
),
// if (controller.dragging)
// Container(

View file

@ -137,14 +137,20 @@ class Message extends StatelessWidget {
// #Pangea
if (event.type == PangeaEventTypes.activityPlan &&
event.room.activityPlan != null) {
return ActivitySummary(
activity: event.room.activityPlan!,
room: event.room,
showInstructions: controller.showInstructions,
toggleInstructions: controller.toggleShowInstructions,
getParticipantOpacity: (role) =>
role == null || role.isFinished ? 0.5 : 1.0,
isParticipantSelected: (id) => controller.room.ownRoleState?.id == id,
return ValueListenableBuilder(
valueListenable: controller.showInstructions,
builder: (context, show, __) {
return ActivitySummary(
activity: event.room.activityPlan!,
room: event.room,
showInstructions: show,
toggleInstructions: controller.toggleShowInstructions,
getParticipantOpacity: (role) =>
role == null || role.isFinished ? 0.5 : 1.0,
isParticipantSelected: (id) =>
controller.room.ownRoleState?.id == id,
);
},
);
}

View file

@ -8,9 +8,12 @@ import 'package:matrix/matrix.dart';
import 'package:slugify/slugify.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/choregrapher_user_settings_extension.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/subscription/widgets/paywall_card.dart';
import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart';
@ -32,7 +35,7 @@ class InputBar extends StatelessWidget {
// #Pangea
// final TextEditingController? controller;
final PangeaTextController? controller;
final String hintText;
final Choreographer choreographer;
// Pangea#
final InputDecoration? decoration;
final ValueChanged<String>? onChanged;
@ -54,7 +57,7 @@ class InputBar extends StatelessWidget {
this.textInputAction,
this.readOnly = false,
// #Pangea
required this.hintText,
required this.choreographer,
// Pangea#
super.key,
});
@ -426,6 +429,22 @@ class InputBar extends StatelessWidget {
}
// #Pangea
String hintText(BuildContext context) {
if (choreographer.itController.open.value) {
return L10n.of(context).buildTranslation;
}
return choreographer.l1Lang != null &&
choreographer.l2Lang != null &&
choreographer.l1Lang!.langCode != LanguageKeys.unknownLanguage &&
choreographer.l2Lang!.langCode != LanguageKeys.unknownLanguage
? L10n.of(context).writeAMessageLangCodes(
choreographer.l1Lang!.displayName,
choreographer.l2Lang!.displayName,
)
: L10n.of(context).writeAMessage;
}
void onInputTap(BuildContext context) {
if (controller == null || controller!.text.isEmpty) return;
final choreographer = controller!.choreographer;
@ -458,148 +477,153 @@ class InputBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
// #Pangea
final enableAutocorrect = MatrixState
.pangeaController.userController.profile.toolSettings.enableAutocorrect;
// Pangea#
return TypeAheadField<Map<String, String?>>(
direction: VerticalDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
controller: controller,
focusNode: focusNode,
hideOnSelect: false,
debounceDuration: const Duration(milliseconds: 50),
// show suggestions after 50ms idle time (default is 300)
// #Pangea
builder: (context, _, focusNode) {
final textField = TextField(
enableSuggestions: enableAutocorrect,
readOnly:
controller != null && (controller!.choreographer.isRunningIT),
autocorrect: enableAutocorrect,
return ListenableBuilder(
listenable: choreographer.textController,
builder: (context, _) {
final enableAutocorrect = MatrixState.pangeaController.userController
.profile.toolSettings.enableAutocorrect;
// Pangea#
return TypeAheadField<Map<String, String?>>(
direction: VerticalDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: (c, e) => markdownContextBuilder(
c,
e,
_,
),
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
final data = content.data;
if (data == null) return;
final file = MatrixFile(
mimeType: content.mimeType,
bytes: data,
name: content.uri.split('/').last,
);
room.sendFileEvent(
file,
shrinkImageMaxDimension: 1600,
);
},
),
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
inputFormatters: [
//LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
//setting max character count to 1000
//after max, nothing else can be typed
LengthLimitingTextInputFormatter(1000),
],
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
},
style: controller?.exceededMaxLength ?? false
? const TextStyle(color: Colors.red)
: null,
onTap: () => onInputTap(context),
decoration: decoration!,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
},
textCapitalization: TextCapitalization.sentences,
);
// fix for issue with typing not working sometimes on Firefox and Safari
return Stack(
alignment: Alignment.centerLeft,
children: [
if (controller != null && controller!.text.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ShrinkableText(
text: hintText,
maxWidth: double.infinity,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
),
hideOnSelect: false,
debounceDuration: const Duration(milliseconds: 50),
// show suggestions after 50ms idle time (default is 300)
// #Pangea
builder: (context, _, focusNode) {
final textField = TextField(
enableSuggestions: enableAutocorrect,
readOnly: controller!.choreographer.isRunningIT,
autocorrect: enableAutocorrect,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: (c, e) => markdownContextBuilder(
c,
e,
_,
),
kIsWeb ? SelectionArea(child: textField) : textField,
],
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
final data = content.data;
if (data == null) return;
final file = MatrixFile(
mimeType: content.mimeType,
bytes: data,
name: content.uri.split('/').last,
);
room.sendFileEvent(
file,
shrinkImageMaxDimension: 1600,
);
},
),
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
inputFormatters: [
//LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
//setting max character count to 1000
//after max, nothing else can be typed
LengthLimitingTextInputFormatter(1000),
],
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
},
style: controller?.exceededMaxLength ?? false
? const TextStyle(color: Colors.red)
: null,
onTap: () => onInputTap(context),
decoration: decoration!,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
},
textCapitalization: TextCapitalization.sentences,
);
return Stack(
alignment: Alignment.centerLeft,
children: [
if (controller != null && controller!.text.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ShrinkableText(
text: hintText(context),
maxWidth: double.infinity,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
),
),
kIsWeb ? SelectionArea(child: textField) : textField,
],
);
},
// builder: (context, controller, focusNode) => TextField(
// controller: controller,
// focusNode: focusNode,
// readOnly: readOnly,
// contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller),
// contentInsertionConfiguration: ContentInsertionConfiguration(
// onContentInserted: (KeyboardInsertedContent content) {
// final data = content.data;
// if (data == null) return;
// final file = MatrixFile(
// mimeType: content.mimeType,
// bytes: data,
// name: content.uri.split('/').last,
// );
// room.sendFileEvent(
// file,
// shrinkImageMaxDimension: 1600,
// );
// },
// ),
// minLines: minLines,
// maxLines: maxLines,
// keyboardType: keyboardType!,
// textInputAction: textInputAction,
// autofocus: autofocus!,
// inputFormatters: [
// LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
// ],
// onSubmitted: (text) {
// // fix for library for now
// // it sets the types for the callback incorrectly
// onSubmitted!(text);
// },
// decoration: decoration!,
// onChanged: (text) {
// // fix for the library for now
// // it sets the types for the callback incorrectly
// onChanged!(text);
// },
// textCapitalization: TextCapitalization.sentences,
// ),
// Pangea#
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) =>
buildSuggestion(c, s, Matrix.of(context).client),
onSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object? error) =>
const SizedBox.shrink(),
loadingBuilder: (BuildContext context) => const SizedBox.shrink(),
// fix loading briefly flickering a dark box
emptyBuilder: (BuildContext context) => const SizedBox
.shrink(), // fix loading briefly showing no suggestions
);
},
// builder: (context, controller, focusNode) => TextField(
// controller: controller,
// focusNode: focusNode,
// readOnly: readOnly,
// contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller),
// contentInsertionConfiguration: ContentInsertionConfiguration(
// onContentInserted: (KeyboardInsertedContent content) {
// final data = content.data;
// if (data == null) return;
// final file = MatrixFile(
// mimeType: content.mimeType,
// bytes: data,
// name: content.uri.split('/').last,
// );
// room.sendFileEvent(
// file,
// shrinkImageMaxDimension: 1600,
// );
// },
// ),
// minLines: minLines,
// maxLines: maxLines,
// keyboardType: keyboardType!,
// textInputAction: textInputAction,
// autofocus: autofocus!,
// inputFormatters: [
// LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
// ],
// onSubmitted: (text) {
// // fix for library for now
// // it sets the types for the callback incorrectly
// onSubmitted!(text);
// },
// decoration: decoration!,
// onChanged: (text) {
// // fix for the library for now
// // it sets the types for the callback incorrectly
// onChanged!(text);
// },
// textCapitalization: TextCapitalization.sentences,
// ),
// Pangea#
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) => buildSuggestion(c, s, Matrix.of(context).client),
onSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object? error) =>
const SizedBox.shrink(),
loadingBuilder: (BuildContext context) => const SizedBox.shrink(),
// fix loading briefly flickering a dark box
emptyBuilder: (BuildContext context) =>
const SizedBox.shrink(), // fix loading briefly showing no suggestions
);
}
}

View file

@ -134,140 +134,145 @@ class ActivityStatsMenuState extends State<ActivityStatsMenu> {
shouldShowEndForAll = false;
}
return Positioned(
top: 0,
left: 0,
right: 0,
bottom: widget.controller.showActivityDropdown ? 0 : null,
child: Column(
children: [
ClipRect(
child: AnimatedAlign(
duration: FluffyThemes.animationDuration,
curve: Curves.easeInOut,
heightFactor: widget.controller.showActivityDropdown ? 1.0 : 0.0,
alignment: Alignment.topCenter,
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy < -2) {
widget.controller.toggleShowDropdown();
}
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
),
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Column(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
ActivitySessionDetailsRow(
icon: Symbols.radar,
iconSize: 16.0,
child: Text(
room.activityPlan!.learningObjective,
style: const TextStyle(fontSize: 12.0),
),
),
ActivitySessionDetailsRow(
icon: Symbols.dictionary,
iconSize: 16.0,
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: [
...room.activityPlan!.vocab.map(
(v) => VocabTile(
vocab: v,
langCode:
room.activityPlan!.req.targetLanguage,
isUsed: (_usedVocab ?? {})
.contains(v.lemma.toLowerCase()),
),
),
],
),
),
],
),
if (!userComplete) ...[
Text(
L10n.of(context).activityDropdownDesc,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
),
if (shouldShowEndForAll)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
side: BorderSide(
color: theme.colorScheme.secondaryContainer,
width: 2,
),
foregroundColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surface,
),
onPressed: () => _finishActivity(forAll: true),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).endForAll,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
if (shouldShowImDone)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: _finishActivity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).endActivity,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
],
],
return ValueListenableBuilder(
valueListenable: widget.controller.showActivityDropdown,
builder: (context, showDropdown, child) {
return Positioned(
top: 0,
left: 0,
right: 0,
bottom: showDropdown ? 0 : null,
child: Column(
children: [
ClipRect(
child: AnimatedAlign(
duration: FluffyThemes.animationDuration,
curve: Curves.easeInOut,
heightFactor: showDropdown ? 1.0 : 0.0,
alignment: Alignment.topCenter,
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy < -2) {
widget.controller.toggleShowDropdown();
}
},
child: child,
),
),
),
),
if (showDropdown)
Expanded(
child: GestureDetector(
onTap: widget.controller.toggleShowDropdown,
child: Container(color: Colors.black.withAlpha(100)),
),
),
],
),
if (widget.controller.showActivityDropdown)
Expanded(
child: GestureDetector(
onTap: widget.controller.toggleShowDropdown,
child: Container(color: Colors.black.withAlpha(100)),
),
);
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
),
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Column(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
ActivitySessionDetailsRow(
icon: Symbols.radar,
iconSize: 16.0,
child: Text(
room.activityPlan!.learningObjective,
style: const TextStyle(fontSize: 12.0),
),
),
ActivitySessionDetailsRow(
icon: Symbols.dictionary,
iconSize: 16.0,
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: [
...room.activityPlan!.vocab.map(
(v) => VocabTile(
vocab: v,
langCode: room.activityPlan!.req.targetLanguage,
isUsed: (_usedVocab ?? {})
.contains(v.lemma.toLowerCase()),
),
),
],
),
),
],
),
],
if (!userComplete) ...[
Text(
L10n.of(context).activityDropdownDesc,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
),
if (shouldShowEndForAll)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
side: BorderSide(
color: theme.colorScheme.secondaryContainer,
width: 2,
),
foregroundColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surface,
),
onPressed: () => _finishActivity(forAll: true),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).endForAll,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
if (shouldShowImDone)
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: _finishActivity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).endActivity,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
],
],
),
),
);
}

View file

@ -50,27 +50,6 @@ class ActivityUserSummaries extends StatelessWidget {
summary: summary,
controller: controller,
),
// Row(
// mainAxisSize: MainAxisSize.min,
// children: userSummaries.map((p) {
// final user = room.getParticipants().firstWhereOrNull(
// (u) => u.id == p.participantId,
// );
// final userRole = assignedRoles.values.firstWhere(
// (role) => role.userId == p.participantId,
// );
// final userRoleInfo = availableRoles[userRole.id]!;
// return ActivityParticipantIndicator(
// availableRole: userRoleInfo,
// assignedRole: userRole,
// avatarUrl:
// userRoleInfo.avatarUrl ?? user?.avatarUrl?.toString(),
// borderRadius: BorderRadius.circular(4),
// selected: controller.highlightedRole?.id == userRole.id,
// onTap: () => controller.highlightRole(userRole),
// );
// }).toList(),
// ),
],
),
);
@ -225,28 +204,33 @@ class ButtonControlledCarouselView extends StatelessWidget {
),
),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: userSummaries.mapIndexed((i, p) {
final user = room.getParticipants().firstWhereOrNull(
(u) => u.id == p.participantId,
ValueListenableBuilder(
valueListenable: controller.highlightedRole,
builder: (context, highlightedRole, __) {
return Row(
mainAxisSize: MainAxisSize.min,
children: userSummaries.mapIndexed((i, p) {
final user = room.getParticipants().firstWhereOrNull(
(u) => u.id == p.participantId,
);
final userRole = assignedRoles.values.firstWhere(
(role) => role.userId == p.participantId,
);
final userRole = assignedRoles.values.firstWhere(
(role) => role.userId == p.participantId,
final userRoleInfo = availableRoles[userRole.id]!;
return ActivityParticipantIndicator(
name: userRoleInfo.name,
userId: p.participantId,
user: user,
borderRadius: BorderRadius.circular(4),
selected: highlightedRole?.id == userRole.id,
onTap: () {
controller.highlightRole(userRole);
controller.carouselController.jumpTo(i * 250.0);
},
);
}).toList(),
);
final userRoleInfo = availableRoles[userRole.id]!;
return ActivityParticipantIndicator(
name: userRoleInfo.name,
userId: p.participantId,
user: user,
borderRadius: BorderRadius.circular(4),
selected: controller.highlightedRole?.id == userRole.id,
onTap: () {
controller.highlightRole(userRole);
controller.carouselController.jumpTo(i * 250.0);
},
);
}).toList(),
},
),
],
);

View file

@ -27,7 +27,7 @@ class ChatInputBar extends StatelessWidget {
hide: controller.choreographer.itController.open,
),
ITBar(choreographer: controller.choreographer),
if (!controller.obscureText) ReplyDisplay(controller),
ReplyDisplay(controller),
PangeaChatInputRow(
controller: controller,
),

View file

@ -8,9 +8,9 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/input_bar.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_send_button.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_ui_extension.dart';
import 'package:fluffychat/pangea/choreographer/igc/start_igc_button.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -27,55 +27,39 @@ class PangeaChatInputRow extends StatelessWidget {
LanguageModel? get activel2 =>
controller.pangeaController.languageController.activeL2Model();
String hintText(BuildContext context) {
if (controller.choreographer.itController.open.value) {
return L10n.of(context).buildTranslation;
}
return activel1 != null &&
activel2 != null &&
activel1!.langCode != LanguageKeys.unknownLanguage &&
activel2!.langCode != LanguageKeys.unknownLanguage
? L10n.of(context).writeAMessageLangCodes(
activel1!.displayName,
activel2!.displayName,
)
: L10n.of(context).writeAMessage;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const height = 48.0;
final state = controller.choreographer.assistanceState;
if (controller.selectMode) {
return const SizedBox(height: height);
}
return ListenableBuilder(
listenable: controller.choreographer,
builder: (context, _) {
return Column(
children: [
CompositedTransformTarget(
link: controller.choreographer.inputLayerLinkAndKey.link,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(8.0),
),
),
child: Row(
key: controller.choreographer.inputLayerLinkAndKey.key,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const SizedBox(width: 4),
AnimatedContainer(
return Column(
children: [
CompositedTransformTarget(
link: controller.choreographer.inputLayerLinkAndKey.link,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(8.0),
),
),
child: Row(
key: controller.choreographer.inputLayerLinkAndKey.key,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const SizedBox(width: 4),
ValueListenableBuilder(
valueListenable: controller.sendController,
builder: (context, text, __) {
return AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
height: height,
width:
controller.sendController.text.isEmpty ? height : 0,
width: text.text.isEmpty &&
!controller.choreographer.itController.open.value
? height
: 0,
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
@ -150,82 +134,90 @@ class PangeaChatInputRow extends StatelessWidget {
),
],
),
),
if (FluffyThemes.isColumnMode(context))
Container(
height: height,
width: height,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).emojis,
icon: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
fillColor: Colors.transparent,
child: child,
);
},
child: Icon(
controller.showEmojiPicker
? Icons.keyboard
: Icons.add_reaction_outlined,
key: ValueKey(controller.showEmojiPicker),
),
),
onPressed: controller.emojiPickerAction,
);
},
),
if (FluffyThemes.isColumnMode(context))
Container(
height: height,
width: height,
alignment: Alignment.center,
child: IconButton(
tooltip: L10n.of(context).emojis,
icon: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
fillColor: Colors.transparent,
child: child,
);
},
child: Icon(
controller.showEmojiPicker
? Icons.keyboard
: Icons.add_reaction_outlined,
key: ValueKey(controller.showEmojiPicker),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
child: InputBar(
room: controller.room,
minLines: 1,
maxLines: 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline,
textInputAction: AppConfig.sendOnEnter == true &&
PlatformInfos.isMobile
? TextInputAction.send
: null,
onSubmitted: (_) => controller.onInputBarSubmitted(),
onSubmitImage: controller.sendImageFromClipBoard,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(
left: 6.0,
right: 6.0,
bottom: 6.0,
top: 3.0,
),
disabledBorder: InputBorder.none,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: controller.onInputBarChanged,
hintText: hintText(context),
onPressed: controller.emojiPickerAction,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
child: InputBar(
room: controller.room,
minLines: 1,
maxLines: 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline,
textInputAction: AppConfig.sendOnEnter == true &&
PlatformInfos.isMobile
? TextInputAction.send
: null,
onSubmitted: (_) => controller.onInputBarSubmitted(),
onSubmitImage: controller.sendImageFromClipBoard,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(
left: 6.0,
right: 6.0,
bottom: 6.0,
top: 3.0,
),
disabledBorder: InputBorder.none,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged: controller.onInputBarChanged,
choreographer: controller.choreographer,
),
StartIGCButton(
controller: controller,
),
Container(
),
),
StartIGCButton(
controller: controller,
initialState: state,
initialForegroundColor: state.stateColor(context),
initialBackgroundColor: state.backgroundColor(context),
),
ValueListenableBuilder(
valueListenable: controller.sendController,
builder: (context, text, __) {
return Container(
height: height,
width: height,
alignment: Alignment.center,
child: PlatformInfos.platformCanRecord &&
controller.sendController.text.isEmpty &&
text.text.isEmpty &&
!controller.choreographer.itController.open.value
? FloatingActionButton.small(
tooltip: L10n.of(context).voiceMessage,
@ -242,14 +234,14 @@ class PangeaChatInputRow extends StatelessWidget {
: ChoreographerSendButton(
controller: controller,
),
),
],
);
},
),
),
],
),
],
);
},
),
),
],
);
}
}

View file

@ -13,10 +13,8 @@ enum AssistanceStateEnum {
fetching,
fetched,
complete,
error,
}
error;
extension AssistanceStateExtension on AssistanceStateEnum {
Color stateColor(context) {
switch (this) {
case AssistanceStateEnum.noSub:
@ -46,4 +44,19 @@ extension AssistanceStateExtension on AssistanceStateEnum {
return AppConfig.success;
}
}
bool get allowsFeedback => switch (this) {
AssistanceStateEnum.notFetched => true,
_ => false,
};
Color backgroundColor(BuildContext context) => switch (this) {
AssistanceStateEnum.noSub ||
AssistanceStateEnum.noMessage ||
AssistanceStateEnum.fetched ||
AssistanceStateEnum.complete ||
AssistanceStateEnum.error =>
Theme.of(context).colorScheme.surfaceContainerHighest,
_ => Theme.of(context).colorScheme.primaryContainer,
};
}

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choregrapher_user_settings_extension.dart';
@ -10,6 +12,7 @@ import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.da
import 'package:fluffychat/pangea/choreographer/igc/igc_controller.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/pangea_message_content_model.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
@ -22,9 +25,6 @@ import 'package:fluffychat/pangea/learning_settings/constants/language_constants
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../widgets/matrix.dart';
import 'choreographer_error_controller.dart';
import 'it/it_controller.dart';
@ -82,18 +82,15 @@ class Choreographer extends ChangeNotifier {
_languageStream =
pangeaController.userController.languageStream.stream.listen((update) {
clear();
notifyListeners();
});
_settingsUpdateStream =
pangeaController.userController.settingsUpdateStream.stream.listen((_) {
notifyListeners();
});
clear();
}
void clear() {
setChoreoMode(ChoreoModeEnum.igc);
_lastChecked = null;
_timesClicked = 0;
_isFetching.value = false;
@ -102,6 +99,7 @@ class Choreographer extends ChangeNotifier {
itController.clearSourceText();
igcController.clear();
_resetDebounceTimer();
setChoreoMode(ChoreoModeEnum.igc);
}
@override
@ -165,51 +163,45 @@ class Choreographer extends ChangeNotifier {
notifyListeners();
}
Future<void> requestLanguageAssistance() =>
_startWritingAssistance(manual: true);
/// Handles any changes to the text input
void _onChange() {
// listener triggers when edit type changes, even if text didn't
// so prevent unnecessary calls
if (_lastChecked != null && _lastChecked == textController.text) {
return;
}
if (_lastChecked == null ||
_lastChecked!.isEmpty ||
textController.text.isEmpty) {
notifyListeners();
}
_lastChecked = textController.text;
if (textController.editType == EditTypeEnum.it) {
return;
}
if (textController.editType == EditTypeEnum.igc ||
textController.editType == EditTypeEnum.itDismissed) {
textController.editType = EditTypeEnum.keyboard;
return;
}
// Close any open IGC overlays
MatrixState.pAnyState.closeOverlay();
if (errorService.isError) return;
igcController.clear();
if (textController.editType == EditTypeEnum.keyboard) {
itController.clearSourceText();
if (igcController.hasIGCTextData ||
itController.sourceText.value != null) {
igcController.clear();
itController.clearSourceText();
notifyListeners();
}
_resetDebounceTimer();
_debounceTimer ??= Timer(
const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart),
() => _getWritingAssistance(),
);
}
_resetDebounceTimer();
_debounceTimer ??= Timer(
const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart),
() => _startWritingAssistance(),
);
//Note: we don't set the keyboard type on each keyboard stroke so this is how we default to
//a change being from the keyboard unless explicitly set to one of the other
//types when that action happens (e.g. an it/igc choice is selected)
textController.editType = EditTypeEnum.keyboard;
}
Future<void> _startWritingAssistance({
Future<void> requestWritingAssistance() =>
_getWritingAssistance(manual: true);
Future<void> _getWritingAssistance({
bool manual = false,
}) async {
if (errorService.isError || isRunningIT) return;
if (assistanceState != AssistanceStateEnum.notFetched) return;
final SubscriptionStatus canSendStatus =
pangeaController.subscriptionController.subscriptionStatus;
@ -228,79 +220,17 @@ class Choreographer extends ChangeNotifier {
textController.text,
chatController.room.getPreviousMessages(),
);
// trigger a re-render of the text field to show IGC matches
textController.setSystemText(
textController.text,
EditTypeEnum.igc,
);
_acceptNormalizationMatches();
_stopLoading();
}
Future<void> send([int recurrence = 0]) async {
// if isFetching, already called to getLanguageHelp and hasn't completed yet
// could happen if user clicked send button multiple times in a row
if (_isFetching.value) return;
if (errorService.isError) {
await _sendWithIGC();
return;
}
if (recurrence > 1) {
ErrorHandler.logError(
e: Exception("Choreographer send exceeded max recurrences"),
level: SentryLevel.warning,
data: {
"currentText": chatController.sendController.text,
"l1LangCode": l1LangCode,
"l2LangCode": l2LangCode,
"choreoRecord": _choreoRecord?.toJson(),
},
);
await _sendWithIGC();
return;
}
if (igcController.canShowFirstMatch) {
throw OpenMatchesException();
} else if (isRunningIT) {
// If the user is in the middle of IT, don't send the message.
// If they've already clicked the send button once, this will
// not be true, so they can still send it if they want.
return;
}
final subscriptionStatus =
pangeaController.subscriptionController.subscriptionStatus;
if (subscriptionStatus != SubscriptionStatus.subscribed) {
if (subscriptionStatus == SubscriptionStatus.shouldShowPaywall) {
throw ShowPaywallException();
}
chatController.send(message: chatController.sendController.text);
return;
}
if (chatController.shouldShowLanguageMismatchPopup) {
chatController.showLanguageMismatchPopup();
return;
}
if (!igcController.hasIGCTextData && !itController.dismissed) {
await _startWritingAssistance();
// it's possible for this not to be true, i.e. if IGC has an error
if (igcController.hasIGCTextData) {
await send(recurrence + 1);
}
} else {
await _sendWithIGC();
}
}
Future<void> _sendWithIGC() async {
if (chatController.sendController.text.trim().isEmpty) {
return;
}
final message = chatController.sendController.text;
final fakeEventId = chatController.sendFakeMessage();
Future<PangeaMessageContentModel> getMessageContent(String message) async {
TokensResponseModel? tokensResp;
if (l1LangCode != null && l2LangCode != null) {
final res = await pangeaController.messageData
@ -320,8 +250,9 @@ class Choreographer extends ChangeNotifier {
final hasOriginalWritten = _choreoRecord?.includedIT == true &&
itController.sourceText.value != null;
chatController.send(
return PangeaMessageContentModel(
message: message,
choreo: _choreoRecord,
originalSent: PangeaRepresentation(
langCode: tokensResp?.detections.firstOrNull?.langCode ??
LanguageKeys.unknownLanguage,
@ -343,11 +274,7 @@ class Choreographer extends ChangeNotifier {
detections: tokensResp.detections,
)
: null,
choreo: _choreoRecord,
tempEventId: fakeEventId,
);
clear();
}
void openIT(PangeaMatchState itMatch) {

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
class ChoreographerSendButton extends StatelessWidget {
@ -19,12 +18,9 @@ class ChoreographerSendButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([
controller.sendController,
controller.choreographer.isFetching,
]),
builder: (context, _) {
return ValueListenableBuilder(
valueListenable: controller.choreographer.isFetching,
builder: (context, fetching, __) {
return Container(
height: 56,
alignment: Alignment.center,
@ -32,9 +28,7 @@ class ChoreographerSendButton extends StatelessWidget {
icon: const Icon(Icons.send_outlined),
color: controller.choreographer.assistanceState
.sendButtonColor(context),
onPressed: controller.choreographer.isFetching.value
? null
: () => _onPressed(context),
onPressed: fetching ? null : () => _onPressed(context),
tooltip: L10n.of(context).send,
),
);

View file

@ -24,7 +24,10 @@ extension ChoregrapherUserSettingsExtension on Choreographer {
}
if (isFetching.value) return AssistanceStateEnum.fetching;
if (!igcController.hasIGCTextData) return AssistanceStateEnum.notFetched;
if (!igcController.hasIGCTextData &&
itController.sourceText.value == null) {
return AssistanceStateEnum.notFetched;
}
return AssistanceStateEnum.complete;
}
}

View file

@ -1,121 +1,143 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
import 'package:flutter/material.dart';
import '../../../pages/chat/chat.dart';
class StartIGCButton extends StatefulWidget {
final ChatController controller;
final AssistanceStateEnum initialState;
final Color initialForegroundColor;
final Color initialBackgroundColor;
const StartIGCButton({
super.key,
required this.controller,
required this.initialState,
required this.initialForegroundColor,
required this.initialBackgroundColor,
});
final ChatController controller;
@override
State<StartIGCButton> createState() => StartIGCButtonState();
State<StartIGCButton> createState() => _StartIGCButtonState();
}
class StartIGCButtonState extends State<StartIGCButton>
with SingleTickerProviderStateMixin {
AssistanceStateEnum get assistanceState =>
widget.controller.choreographer.assistanceState;
AnimationController? _controller;
class _StartIGCButtonState extends State<StartIGCButton>
with TickerProviderStateMixin {
AnimationController? _spinController;
late Animation<double> _rotation;
AnimationController? _colorController;
late Animation<Color?> _iconColor;
late Animation<Color?> _backgroundColor;
AssistanceStateEnum? _prevState;
AssistanceStateEnum get state =>
widget.controller.choreographer.assistanceState;
bool _shouldStop = false;
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
widget.controller.choreographer.addListener(_updateSpinnerState);
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (_shouldStop) {
_spinController?.stop();
_spinController?.value = 0;
} else {
_spinController?.forward(from: 0);
}
}
});
_rotation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _spinController!,
curve: Curves.linear,
),
);
_colorController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_prevState = widget.initialState;
_iconColor = AlwaysStoppedAnimation(widget.initialForegroundColor);
_backgroundColor = AlwaysStoppedAnimation(widget.initialBackgroundColor);
_colorController!.forward(from: 0.0);
widget.controller.choreographer.addListener(_handleStateChange);
}
@override
void dispose() {
_controller?.dispose();
widget.controller.choreographer.removeListener(_handleStateChange);
_spinController?.dispose();
_colorController?.dispose();
super.dispose();
}
void _updateSpinnerState() {
if (_prevState != AssistanceStateEnum.fetching &&
assistanceState == AssistanceStateEnum.fetching) {
_controller?.repeat();
} else if (_prevState == AssistanceStateEnum.fetching &&
assistanceState != AssistanceStateEnum.fetching) {
_controller?.reset();
}
if (mounted) {
setState(() => _prevState = assistanceState);
}
}
void _handleStateChange() {
final prev = _prevState;
final current = state;
_prevState = current;
bool get _enableFeedback {
return ![
AssistanceStateEnum.fetching,
AssistanceStateEnum.fetched,
AssistanceStateEnum.complete,
AssistanceStateEnum.noMessage,
AssistanceStateEnum.noSub,
AssistanceStateEnum.error,
].contains(assistanceState);
}
if (!mounted || prev == current) return;
final newIconColor = current.stateColor(context);
final newBgColor = current.backgroundColor(context);
final oldIconColor = _iconColor.value;
final oldBgColor = _backgroundColor.value;
Future<void> _onTap() async {
if (!_enableFeedback) return;
if (widget.controller.shouldShowLanguageMismatchPopup) {
widget.controller.showLanguageMismatchPopup();
} else {
await widget.controller.choreographer.requestLanguageAssistance();
final openMatch =
widget.controller.choreographer.igcController.firstOpenMatch;
widget.controller.onSelectMatch(openMatch);
}
}
// Create tweens from current new colors
_iconColor = ColorTween(
begin: oldIconColor,
end: newIconColor,
).animate(_colorController!);
_backgroundColor = ColorTween(
begin: oldBgColor,
end: newBgColor,
).animate(_colorController!);
_colorController!.forward(from: 0.0);
Color get _backgroundColor {
switch (assistanceState) {
case AssistanceStateEnum.noSub:
case AssistanceStateEnum.noMessage:
case AssistanceStateEnum.fetched:
case AssistanceStateEnum.complete:
case AssistanceStateEnum.error:
return Theme.of(context).colorScheme.surfaceContainerHighest;
case AssistanceStateEnum.notFetched:
case AssistanceStateEnum.fetching:
return Theme.of(context).colorScheme.primaryContainer;
if (current == AssistanceStateEnum.fetching) {
_shouldStop = false;
_spinController!.forward(from: 0.0);
} else if (prev == AssistanceStateEnum.fetching) {
_shouldStop = true;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: widget.controller.choreographer.textController,
builder: (context, _, __) {
final icon = Icon(
size: 36,
Icons.autorenew_rounded,
color: assistanceState.stateColor(context),
);
if (_colorController == null || _spinController == null) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_colorController!, _spinController!]),
builder: (context, child) {
final enableFeedback = state.allowsFeedback;
return Tooltip(
message: _enableFeedback ? L10n.of(context).check : "",
message: enableFeedback ? L10n.of(context).check : "",
child: Material(
elevation: _enableFeedback ? 4.0 : 0.0,
borderRadius: BorderRadius.circular(99.0),
elevation: enableFeedback ? 4.0 : 0.0,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
shadowColor: Theme.of(context).colorScheme.surface.withAlpha(128),
child: InkWell(
enableFeedback: _enableFeedback,
onTap: _enableFeedback ? _onTap : null,
enableFeedback: enableFeedback,
customBorder: const CircleBorder(),
onLongPress: _enableFeedback
onTap: enableFeedback
? widget.controller.onRequestWritingAssistance
: null,
onLongPress: enableFeedback
? () => showDialog(
context: context,
builder: (c) => const SettingsLearning(),
@ -125,35 +147,40 @@ class StartIGCButtonState extends State<StartIGCButton>
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
Container(
height: 40.0,
width: 40.0,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
color: _backgroundColor.value,
),
),
_controller != null
? RotationTransition(
turns: Tween(begin: 0.0, end: math.pi * 2)
.animate(_controller!),
child: icon,
)
: icon,
AnimatedContainer(
AnimatedBuilder(
animation: _rotation,
builder: (context, child) {
return Transform.rotate(
angle: _rotation.value * 2 * 3.14159,
child: child,
);
},
child: Icon(
Icons.autorenew_rounded,
size: 36,
color: _iconColor.value,
),
),
Container(
width: 20,
height: 20,
duration: FluffyThemes.animationDuration,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor,
color: _backgroundColor.value,
),
),
Icon(
size: 16,
Icons.check,
color: assistanceState.stateColor(context),
color: _iconColor.value,
),
],
),

View file

@ -24,7 +24,6 @@ class ITController {
final ValueNotifier<ITStepModel?> _currentITStep = ValueNotifier(null);
final ValueNotifier<bool> _open = ValueNotifier(false);
final ValueNotifier<bool> _editing = ValueNotifier(false);
bool _dismissed = false;
ITController(this.onError);
@ -32,7 +31,6 @@ class ITController {
ValueNotifier<bool> get editing => _editing;
ValueNotifier<ITStepModel?> get currentITStep => _currentITStep;
ValueNotifier<String?> get sourceText => _sourceText;
bool get dismissed => _dismissed;
ITRequestModel _request(String textInput) {
assert(_sourceText.value != null);
@ -57,12 +55,11 @@ class ITController {
);
}
void clear({bool dismissed = false}) {
void clear() {
MatrixState.pAnyState.closeOverlay("it_feedback_card");
_open.value = false;
_editing.value = false;
_dismissed = dismissed;
_queue.clear();
_currentITStep.value = null;
_goldRouteTracker = null;
@ -85,7 +82,7 @@ class ITController {
continueIT();
}
void closeIT() => clear(dismissed: true);
void closeIT() => clear();
void setEditing(bool value) {
_editing.value = value;
@ -193,6 +190,10 @@ class ITController {
final goldContinuances = _goldRouteTracker!.continuances;
String currentText = goldContinuances[0].text;
for (int i = 1; i < goldContinuances.length; i++) {
if (_sourceText.value == null || !_open.value) {
return;
}
final completer = Completer<ITStepModel>();
_queue.add(completer);
final resp = await _safeRequest(currentText);

View file

@ -0,0 +1,21 @@
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
class PangeaMessageContentModel {
final String message;
final PangeaRepresentation? originalSent;
final PangeaRepresentation? originalWritten;
final PangeaMessageTokens? tokensSent;
final PangeaMessageTokens? tokensWritten;
final ChoreoRecordModel? choreo;
const PangeaMessageContentModel({
required this.message,
this.originalSent,
this.originalWritten,
this.tokensSent,
this.tokensWritten,
this.choreo,
});
}

View file

@ -90,7 +90,6 @@ class ModelKey {
static const String messageTagMorphEdit = "morph_edit";
static const String messageTagLemmaEdit = "lemma_edit";
static const String messageTagActivityPlan = "activity_plan";
static const String tempEventId = "temporary_event_id";
static const String baseDefinition = "base_definition";
static const String targetDefinition = "target_definition";

View file

@ -54,50 +54,45 @@ class OverlayUtil {
}
final OverlayEntry entry = OverlayEntry(
builder: (context) => AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Stack(
children: [
if (backDropToDismiss)
IgnorePointer(
ignoring: ignorePointer,
child: TransparentBackdrop(
backgroundColor: backgroundColor,
onDismiss: onDismiss,
blurBackground: blurBackground,
),
builder: (context) => Stack(
children: [
if (backDropToDismiss)
IgnorePointer(
ignoring: ignorePointer,
child: TransparentBackdrop(
backgroundColor: backgroundColor,
onDismiss: onDismiss,
blurBackground: blurBackground,
),
Positioned(
top: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
right: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
left: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
bottom: (position == OverlayPositionEnum.centered) ? 0 : null,
child: (position != OverlayPositionEnum.transform)
? child
: CompositedTransformFollower(
targetAnchor: targetAnchor ?? Alignment.topCenter,
followerAnchor:
followerAnchor ?? Alignment.bottomCenter,
link: MatrixState.pAnyState
.layerLinkAndKey(transformTargetId!)
.link,
showWhenUnlinked: false,
offset: offset ?? Offset.zero,
child: child,
),
),
],
),
Positioned(
top: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
right: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
left: (position == OverlayPositionEnum.centered ||
position == OverlayPositionEnum.top)
? 0
: null,
bottom: (position == OverlayPositionEnum.centered) ? 0 : null,
child: (position != OverlayPositionEnum.transform)
? child
: CompositedTransformFollower(
targetAnchor: targetAnchor ?? Alignment.topCenter,
followerAnchor: followerAnchor ?? Alignment.bottomCenter,
link: MatrixState.pAnyState
.layerLinkAndKey(transformTargetId!)
.link,
showWhenUnlinked: false,
offset: offset ?? Offset.zero,
child: child,
),
),
],
),
);

View file

@ -2,11 +2,9 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import '../../../config/themes.dart';
import '../../../widgets/matrix.dart';
class TransparentBackdrop extends StatefulWidget {
class TransparentBackdrop extends StatelessWidget {
final Color? backgroundColor;
final VoidCallback? onDismiss;
final bool blurBackground;
@ -18,91 +16,38 @@ class TransparentBackdrop extends StatefulWidget {
this.blurBackground = false,
});
@override
TransparentBackdropState createState() => TransparentBackdropState();
}
class TransparentBackdropState extends State<TransparentBackdrop>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityTween;
late Animation<double> _blurTween;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration:
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
vsync: this,
);
_opacityTween = Tween<double>(begin: 0.0, end: 0.8).animate(
CurvedAnimation(
parent: _controller,
curve: FluffyThemes.animationCurve,
),
);
_blurTween = Tween<double>(begin: 0.0, end: 3.0).animate(
CurvedAnimation(
parent: _controller,
curve: FluffyThemes.animationCurve,
),
);
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) _controller.forward();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _opacityTween,
builder: (context, _) {
return Material(
borderOnForeground: false,
color: widget.backgroundColor
?.withAlpha((_opacityTween.value * 255).round()) ??
Colors.transparent,
clipBehavior: Clip.antiAlias,
child: InkWell(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
onTap: () {
if (widget.onDismiss != null) {
widget.onDismiss!();
}
MatrixState.pAnyState.closeOverlay();
},
child: AnimatedBuilder(
animation: _blurTween,
builder: (context, _) {
return BackdropFilter(
filter: widget.blurBackground
? ImageFilter.blur(
sigmaX: _blurTween.value,
sigmaY: _blurTween.value,
)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.transparent,
),
);
},
),
return Material(
borderOnForeground: false,
color:
backgroundColor?.withAlpha((0.8 * 255).round()) ?? Colors.transparent,
clipBehavior: Clip.antiAlias,
child: InkWell(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
onTap: () {
if (onDismiss != null) {
onDismiss!();
}
MatrixState.pAnyState.closeOverlay();
},
child: BackdropFilter(
filter: blurBackground
? ImageFilter.blur(
sigmaX: 3.0,
sigmaY: 3.0,
)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Container(
height: double.infinity,
width: double.infinity,
color: Colors.transparent,
),
);
},
),
),
);
}
}

View file

@ -158,11 +158,11 @@ extension EventsRoomExtension on Room {
return content;
}
String sendFakeMessage({
Future<String> sendFakeMessage({
required String text,
Event? inReplyTo,
String? editEventId,
}) {
}) async {
// Create new transaction id
final messageID = client.generateUniqueTransactionId();
@ -180,9 +180,29 @@ extension EventsRoomExtension on Room {
room: this,
originServerTs: DateTime.now(),
status: EventStatus.sending,
unsigned: {
messageSendingStatusKey: EventStatus.sending.intValue,
'transaction_id': messageID,
},
);
timeline?.events.insert(0, event);
final syncUpdate = SyncUpdate(
nextBatch: '',
rooms: RoomsUpdate(
join: {
id: JoinedRoomUpdate(
timeline: TimelineUpdate(
events: [
event,
],
),
),
},
),
);
await client.database.transaction(() async {
await client.handleSync(syncUpdate);
});
return messageID;
}
@ -202,7 +222,6 @@ extension EventsRoomExtension on Room {
PangeaMessageTokens? tokensWritten,
ChoreoRecordModel? choreo,
String? messageTag,
String? tempEventId,
}) {
// if (parseCommands) {
// return client.parseAndRunCommand(
@ -238,9 +257,6 @@ extension EventsRoomExtension on Room {
if (messageTag != null) {
event[ModelKey.messageTags] = messageTag;
}
if (tempEventId != null) {
event[ModelKey.tempEventId] = tempEventId;
}
if (parseMarkdown) {
final html = markdown(

View file

@ -51,6 +51,10 @@ class LoadParticipantsBuilderState extends State<LoadParticipantsBuilder> {
}
Future<void> _loadParticipants() async {
if (widget.room == null || widget.room!.participantListComplete) {
return;
}
try {
setState(() {
loading = true;

View file

@ -452,31 +452,29 @@ class MessageReactionPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (chatController.selectedEvents.length != 1) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final sentReactions = <String>{};
final event = chatController.selectedEvents.first;
sentReactions.addAll(
event
.aggregatedEvents(
chatController.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
final event = chatController.selectedEvents.firstOrNull;
if (event != null) {
sentReactions.addAll(
event
.aggregatedEvents(
chatController.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
}
return Material(
elevation: 4,
@ -512,7 +510,7 @@ class MessageReactionPicker extends StatelessWidget {
),
onPressed: sentReactions.contains(emoji)
? null
: () => event.room.sendReaction(
: () => event?.room.sendReaction(
event.eventId,
emoji,
),
@ -583,7 +581,7 @@ class MessageReactionPicker extends StatelessWidget {
);
if (emoji == null) return;
if (sentReactions.contains(emoji)) return;
await event.room.sendReaction(
await event?.room.sendReaction(
event.eventId,
emoji,
);

View file

@ -747,7 +747,6 @@ class MoreButton extends StatelessWidget {
break;
case MessageActions.info:
controller.showEventInfo();
controller.clearSelectedEvents();
break;
case MessageActions.deleteOnError:
controller.deleteErrorEventsAction();

View file

@ -212,27 +212,25 @@ class WordZoomWidget extends StatelessWidget {
return Material(
type: MaterialType.transparency,
child: SelectionArea(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 4.0,
),
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 4.0,
),
height: AppConfig.toolbarMaxHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
content,
],
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
),
height: AppConfig.toolbarMaxHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
content,
],
),
),
);
}