From 0ec17d615e83a8b9b9d919390c835b31ca571175 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 13 Nov 2025 12:51:26 -0500 Subject: [PATCH] refactor: expose text updates from it/igc via streams and respond to those streams in the choreographer --- lib/pages/chat/chat.dart | 213 +++-- lib/pages/chat/chat_input_row.dart | 769 +++++++++--------- lib/pages/chat/input_bar.dart | 408 ++++------ .../chat/widgets/pangea_chat_input_row.dart | 5 +- lib/pangea/choreographer/choreographer.dart | 197 ++--- .../choreographer_state_extension.dart | 4 +- .../choreographer/igc/igc_controller.dart | 337 ++++++-- lib/pangea/choreographer/igc/igc_repo.dart | 21 +- .../igc/igc_text_data_model.dart | 265 ------ .../igc/pangea_match_state_model.dart | 11 + .../igc/pangea_match_status_enum.dart | 3 + lib/pangea/choreographer/igc/span_card.dart | 20 +- .../choreographer/igc/start_igc_button.dart | 25 +- lib/pangea/choreographer/it/it_bar.dart | 72 +- .../choreographer/it/it_controller.dart | 56 +- .../text_editing/pangea_text_controller.dart | 19 +- .../widgets/language_mismatch_popup.dart | 9 +- 17 files changed, 1123 insertions(+), 1311 deletions(-) delete mode 100644 lib/pangea/choreographer/igc/igc_text_data_model.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index c94d46dcf..670b8215e 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -32,6 +32,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; +import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; import 'package:fluffychat/pangea/analytics_misc/message_analytics_feedback.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; @@ -424,7 +425,6 @@ class ChatController extends State @override void initState() { inputFocus = FocusNode(onKeyEvent: _customEnterKeyHandling); - choreographer = Choreographer(inputFocus); scrollController.addListener(_updateScrollController); // #Pangea @@ -441,115 +441,111 @@ class ChatController extends State sendingClient = Matrix.of(context).client; readMarkerEventId = room.hasNewMessages ? room.fullyRead : ''; WidgetsBinding.instance.addObserver(this); - // #Pangea - if (!mounted) return; - Future.delayed(const Duration(seconds: 1), () async { - if (!mounted) return; - if (mounted) { - pangeaController.languageController.showDialogOnEmptyLanguage( - context, - () => Future.delayed( - Duration.zero, - () => setState(() {}), - ), - ); - } - }); - - _levelSubscription = pangeaController.getAnalytics.stateStream - .where( - (update) => - update is Map && - (update['level_up'] != null || update['unlocked_constructs'] != null), - ) - .listen( - (update) { - if (update['level_up'] != null) { - LevelUpUtil.showLevelUpDialog( - update['upper_level'], - update['lower_level'], - context, - ); - } else if (update['unlocked_constructs'] != null) { - ConstructNotificationUtil.addUnlockedConstruct( - List.from(update['unlocked_constructs']), - context, - ); - } - }, - ); - - _analyticsSubscription = - pangeaController.getAnalytics.analyticsStream.stream.listen((update) { - if (update.targetID == null) return; - OverlayUtil.showOverlay( - overlayKey: "${update.targetID ?? ""}_points", - followerAnchor: Alignment.bottomCenter, - targetAnchor: Alignment.bottomCenter, - context: context, - child: PointsGainedAnimation( - points: update.points, - targetID: update.targetID!, - ), - transformTargetId: update.targetID ?? "", - closePrevOverlay: false, - backDropToDismiss: false, - ignorePointer: true, - ); - }); - - _botAudioSubscription = room.client.onSync.stream - .where( - (update) => update.rooms?.join?[roomId]?.timeline?.events != null, - ) - .listen((update) async { - final timeline = update.rooms!.join![roomId]!.timeline!; - final botAudioEvent = timeline.events!.firstWhereOrNull( - (e) => - e.senderId == BotName.byEnvironment && - e.content.tryGet('msgtype') == MessageTypes.Audio && - DateTime.now().difference(e.originServerTs) < - const Duration(seconds: 10), - ); - if (botAudioEvent == null) return; - - final matrix = Matrix.of(context); - matrix.voiceMessageEventId.value = botAudioEvent.eventId; - matrix.audioPlayer?.dispose(); - matrix.audioPlayer = AudioPlayer(); - - final event = Event.fromMatrixEvent(botAudioEvent, room); - final audioFile = await event.getPangeaAudioFile(); - if (audioFile == null) return; - - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - - File? file; - file = File('${tempDir.path}/${audioFile.name}'); - await file.writeAsBytes(audioFile.bytes); - matrix.audioPlayer!.setFilePath(file.path); - } else { - matrix.audioPlayer!.setAudioSource( - BytesAudioSource( - audioFile.bytes, - audioFile.mimeType, - ), - ); - } - - matrix.audioPlayer!.play(); - }); - // Pangea# _tryLoadTimeline(); if (kIsWeb) { - // #Pangea - onFocusSub?.cancel(); - // Pangea# onFocusSub = html.window.onFocus.listen((_) => setReadMarker()); } + + // #Pangea + _pangeaInit(); + // Pangea# } + // #Pangea + void _onLevelUp(dynamic update) { + if (update['level_up'] != null) { + LevelUpUtil.showLevelUpDialog( + update['upper_level'], + update['lower_level'], + context, + ); + } else if (update['unlocked_constructs'] != null) { + ConstructNotificationUtil.addUnlockedConstruct( + List.from(update['unlocked_constructs']), + context, + ); + } + } + + void _onAnalyticsUpdate(AnalyticsStreamUpdate update) { + if (update.targetID == null) return; + OverlayUtil.showOverlay( + overlayKey: "${update.targetID ?? ""}_points", + followerAnchor: Alignment.bottomCenter, + targetAnchor: Alignment.bottomCenter, + context: context, + child: PointsGainedAnimation( + points: update.points, + targetID: update.targetID!, + ), + transformTargetId: update.targetID ?? "", + closePrevOverlay: false, + backDropToDismiss: false, + ignorePointer: true, + ); + } + + Future _botAudioListener(SyncUpdate update) async { + if (update.rooms?.join?[roomId]?.timeline?.events == null) return; + final timeline = update.rooms!.join![roomId]!.timeline!; + final botAudioEvent = timeline.events!.firstWhereOrNull( + (e) => + e.senderId == BotName.byEnvironment && + e.content.tryGet('msgtype') == MessageTypes.Audio && + DateTime.now().difference(e.originServerTs) < + const Duration(seconds: 10), + ); + if (botAudioEvent == null) return; + + final matrix = Matrix.of(context); + matrix.voiceMessageEventId.value = botAudioEvent.eventId; + matrix.audioPlayer?.dispose(); + matrix.audioPlayer = AudioPlayer(); + + final event = Event.fromMatrixEvent(botAudioEvent, room); + final audioFile = await event.getPangeaAudioFile(); + if (audioFile == null) return; + + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + + File? file; + file = File('${tempDir.path}/${audioFile.name}'); + await file.writeAsBytes(audioFile.bytes); + matrix.audioPlayer!.setFilePath(file.path); + } else { + matrix.audioPlayer!.setAudioSource( + BytesAudioSource( + audioFile.bytes, + audioFile.mimeType, + ), + ); + } + + matrix.audioPlayer!.play(); + } + + void _pangeaInit() { + choreographer = Choreographer(inputFocus); + _levelSubscription = + pangeaController.getAnalytics.stateStream.listen(_onLevelUp); + + _analyticsSubscription = pangeaController + .getAnalytics.analyticsStream.stream + .listen(_onAnalyticsUpdate); + + _botAudioSubscription = room.client.onSync.stream.listen(_botAudioListener); + + Future.delayed(const Duration(seconds: 1), () async { + if (!mounted) return; + pangeaController.languageController.showDialogOnEmptyLanguage( + context, + () => () => setState(() {}), + ); + }); + } + // Pangea# + void _tryLoadTimeline() async { final initialEventId = widget.eventId; loadTimelineFuture = _getTimeline(); @@ -781,7 +777,7 @@ class ChatController extends State // inputFocus.removeListener(_inputFocusListener); // Pangea# onFocusSub?.cancel(); - //#Pangea + // #Pangea WidgetsBinding.instance.removeObserver(this); _storeInputTimeoutTimer?.cancel(); _displayChatDetailsColumn.dispose(); @@ -2188,14 +2184,15 @@ class ChatController extends State } void showNextMatch() { - final match = choreographer.igcController.firstOpenMatch; + MatrixState.pAnyState.closeOverlay(); + final match = choreographer.igcController.openMatches.firstOrNull; if (match == null) { inputFocus.requestFocus(); return; } match.updatedMatch.isITStart - ? choreographer.openIT(match) + ? choreographer.itController.openIT(sendController.text) : OverlayUtil.showIGCMatch( match, choreographer, @@ -2232,6 +2229,7 @@ class ChatController extends State OverlayUtil.showPositionedCard( context: context, cardToShow: LanguageMismatchPopup( + overlayId: 'language_mismatch_popup', onConfirm: () async { await MatrixState.pangeaController.userController.updateProfile( (profile) { @@ -2249,6 +2247,7 @@ class ChatController extends State maxHeight: 325, maxWidth: 325, transformTargetId: ChoreoConstants.inputTransformTargetKey, + overlayKey: 'language_mismatch_popup', ); } diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 3874ee4d8..97dbc47b3 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -1,401 +1,394 @@ -import 'package:flutter/material.dart'; +// import 'package:flutter/material.dart'; -import 'package:animations/animations.dart'; -import 'package:matrix/matrix.dart'; +// import 'package:animations/animations.dart'; +// import 'package:matrix/matrix.dart'; -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'; +// 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'; -class ChatInputRow extends StatelessWidget { - final ChatController controller; +// class ChatInputRow extends StatelessWidget { +// final ChatController controller; - const ChatInputRow(this.controller, {super.key}); +// const ChatInputRow(this.controller, {super.key}); - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); +// @override +// Widget build(BuildContext context) { +// final theme = Theme.of(context); - const height = 48.0; +// const height = 48.0; - if (!controller.room.otherPartyCanReceiveMessages) { - return Center( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - L10n.of(context).otherPartyNotLoggedIn, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ), - ); - } +// if (!controller.room.otherPartyCanReceiveMessages) { +// return Center( +// child: Padding( +// padding: const EdgeInsets.all(12.0), +// child: Text( +// L10n.of(context).otherPartyNotLoggedIn, +// style: theme.textTheme.bodySmall, +// textAlign: TextAlign.center, +// ), +// ), +// ); +// } - final selectedTextButtonStyle = TextButton.styleFrom( - foregroundColor: theme.colorScheme.onTertiaryContainer, - ); +// final selectedTextButtonStyle = TextButton.styleFrom( +// foregroundColor: theme.colorScheme.onTertiaryContainer, +// ); - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: controller.selectMode - ? [ - if (controller.selectedEvents - .every((event) => event.status == EventStatus.error)) - SizedBox( - height: height, - child: TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.orange, - ), - onPressed: controller.deleteErrorEventsAction, - child: Row( - children: [ - const Icon(Icons.delete), - Text(L10n.of(context).delete), - ], - ), - ), - ) - else - SizedBox( - height: height, - child: TextButton( - style: selectedTextButtonStyle, - onPressed: controller.forwardEventsAction, - child: Row( - children: [ - const Icon(Icons.keyboard_arrow_left_outlined), - Text(L10n.of(context).forward), - ], - ), - ), - ), - controller.selectedEvents.length == 1 - ? controller.selectedEvents.first - .getDisplayEvent(controller.timeline!) - .status - .isSent - ? SizedBox( - height: height, - child: TextButton( - style: selectedTextButtonStyle, - onPressed: controller.replyAction, - child: Row( - children: [ - Text(L10n.of(context).reply), - const Icon(Icons.keyboard_arrow_right), - ], - ), - ), - ) - : SizedBox( - height: height, - child: TextButton( - style: selectedTextButtonStyle, - onPressed: controller.sendAgainAction, - child: Row( - children: [ - Text(L10n.of(context).tryToSendAgain), - const SizedBox(width: 4), - const Icon(Icons.send_outlined, size: 16), - ], - ), - ), - ) - : const SizedBox.shrink(), - ] - : [ - const SizedBox(width: 4), - AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - width: controller.sendController.text.isNotEmpty ? 0 : height, - height: height, - alignment: Alignment.center, - decoration: const BoxDecoration(), - clipBehavior: Clip.hardEdge, - child: PopupMenuButton( - useRootNavigator: true, - icon: const Icon(Icons.add_circle_outline), - iconColor: theme.colorScheme.onPrimaryContainer, - onSelected: controller.onAddPopupMenuButtonSelected, - itemBuilder: (BuildContext context) => - >[ - if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'location', - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.gps_fixed_outlined), - ), - title: Text(L10n.of(context).shareLocation), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'image', - child: ListTile( - leading: CircleAvatar( - backgroundColor: theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.photo_outlined), - ), - title: Text(L10n.of(context).sendImage), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'video', - child: ListTile( - leading: CircleAvatar( - backgroundColor: theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.video_camera_back_outlined), - ), - title: Text(L10n.of(context).sendVideo), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'file', - child: ListTile( - leading: CircleAvatar( - backgroundColor: theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.attachment_outlined), - ), - title: Text(L10n.of(context).sendFile), - contentPadding: const EdgeInsets.all(0), - ), - ), - ], - ), - ), - if (PlatformInfos.isMobile) - AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - width: controller.sendController.text.isNotEmpty ? 0 : height, - height: height, - alignment: Alignment.center, - decoration: const BoxDecoration(), - clipBehavior: Clip.hardEdge, - child: PopupMenuButton( - useRootNavigator: true, - icon: const Icon(Icons.camera_alt_outlined), - onSelected: controller.onAddPopupMenuButtonSelected, - iconColor: theme.colorScheme.onPrimaryContainer, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'camera-video', - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.videocam_outlined), - ), - title: Text(L10n.of(context).recordAVideo), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'camera', - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: theme.colorScheme.primaryContainer, - child: const Icon(Icons.camera_alt_outlined), - ), - title: Text(L10n.of(context).takeAPhoto), - contentPadding: const EdgeInsets.all(0), - ), - ), - ], - ), - ), - Container( - height: height, - width: height, - alignment: Alignment.center, - child: IconButton( - tooltip: L10n.of(context).emojis, - color: theme.colorScheme.onPrimaryContainer, - icon: PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation 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 (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), - ), - 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, - // #Pangea - // onSubmitted: controller.onInputBarSubmitted, - onSubmitted: (_) => controller.onInputBarSubmitted(), - // Pangea# - onSubmitImage: controller.sendImageFromClipBoard, - focusNode: controller.inputFocus, - controller: controller.sendController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only( - left: 6.0, - right: 6.0, - bottom: 6.0, - top: 3.0, - ), - hintText: L10n.of(context).writeAMessage, - hintMaxLines: 1, - border: InputBorder.none, - enabledBorder: InputBorder.none, - filled: false, - ), - onChanged: controller.onInputBarChanged, - // #Pangea - choreographer: controller.choreographer, - showNextMatch: controller.showNextMatch, - // Pangea# - ), - ), - ), - Container( - height: height, - width: height, - alignment: Alignment.center, - child: PlatformInfos.platformCanRecord && - controller.sendController.text.isEmpty - ? FloatingActionButton.small( - tooltip: L10n.of(context).voiceMessage, - onPressed: controller.voiceMessageAction, - elevation: 0, - heroTag: null, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(height), - ), - backgroundColor: theme.bubbleColor, - foregroundColor: theme.onBubbleColor, - child: const Icon(Icons.mic_none_outlined), - ) - : FloatingActionButton.small( - tooltip: L10n.of(context).send, - onPressed: controller.send, - elevation: 0, - heroTag: null, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(height), - ), - backgroundColor: theme.bubbleColor, - foregroundColor: theme.onBubbleColor, - child: const Icon(Icons.send_outlined), - ), - ), - ], - ); - } -} +// return Row( +// crossAxisAlignment: CrossAxisAlignment.end, +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: controller.selectMode +// ? [ +// if (controller.selectedEvents +// .every((event) => event.status == EventStatus.error)) +// SizedBox( +// height: height, +// child: TextButton( +// style: TextButton.styleFrom( +// foregroundColor: Colors.orange, +// ), +// onPressed: controller.deleteErrorEventsAction, +// child: Row( +// children: [ +// const Icon(Icons.delete), +// Text(L10n.of(context).delete), +// ], +// ), +// ), +// ) +// else +// SizedBox( +// height: height, +// child: TextButton( +// style: selectedTextButtonStyle, +// onPressed: controller.forwardEventsAction, +// child: Row( +// children: [ +// const Icon(Icons.keyboard_arrow_left_outlined), +// Text(L10n.of(context).forward), +// ], +// ), +// ), +// ), +// controller.selectedEvents.length == 1 +// ? controller.selectedEvents.first +// .getDisplayEvent(controller.timeline!) +// .status +// .isSent +// ? SizedBox( +// height: height, +// child: TextButton( +// style: selectedTextButtonStyle, +// onPressed: controller.replyAction, +// child: Row( +// children: [ +// Text(L10n.of(context).reply), +// const Icon(Icons.keyboard_arrow_right), +// ], +// ), +// ), +// ) +// : SizedBox( +// height: height, +// child: TextButton( +// style: selectedTextButtonStyle, +// onPressed: controller.sendAgainAction, +// child: Row( +// children: [ +// Text(L10n.of(context).tryToSendAgain), +// const SizedBox(width: 4), +// const Icon(Icons.send_outlined, size: 16), +// ], +// ), +// ), +// ) +// : const SizedBox.shrink(), +// ] +// : [ +// const SizedBox(width: 4), +// AnimatedContainer( +// duration: FluffyThemes.animationDuration, +// curve: FluffyThemes.animationCurve, +// width: controller.sendController.text.isNotEmpty ? 0 : height, +// height: height, +// alignment: Alignment.center, +// decoration: const BoxDecoration(), +// clipBehavior: Clip.hardEdge, +// child: PopupMenuButton( +// useRootNavigator: true, +// icon: const Icon(Icons.add_circle_outline), +// iconColor: theme.colorScheme.onPrimaryContainer, +// onSelected: controller.onAddPopupMenuButtonSelected, +// itemBuilder: (BuildContext context) => +// >[ +// if (PlatformInfos.isMobile) +// PopupMenuItem( +// value: 'location', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: +// theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.gps_fixed_outlined), +// ), +// title: Text(L10n.of(context).shareLocation), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// PopupMenuItem( +// value: 'image', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.photo_outlined), +// ), +// title: Text(L10n.of(context).sendImage), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// PopupMenuItem( +// value: 'video', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.video_camera_back_outlined), +// ), +// title: Text(L10n.of(context).sendVideo), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// PopupMenuItem( +// value: 'file', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.attachment_outlined), +// ), +// title: Text(L10n.of(context).sendFile), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// ], +// ), +// ), +// if (PlatformInfos.isMobile) +// AnimatedContainer( +// duration: FluffyThemes.animationDuration, +// curve: FluffyThemes.animationCurve, +// width: controller.sendController.text.isNotEmpty ? 0 : height, +// height: height, +// alignment: Alignment.center, +// decoration: const BoxDecoration(), +// clipBehavior: Clip.hardEdge, +// child: PopupMenuButton( +// useRootNavigator: true, +// icon: const Icon(Icons.camera_alt_outlined), +// onSelected: controller.onAddPopupMenuButtonSelected, +// iconColor: theme.colorScheme.onPrimaryContainer, +// itemBuilder: (context) => [ +// PopupMenuItem( +// value: 'camera-video', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: +// theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.videocam_outlined), +// ), +// title: Text(L10n.of(context).recordAVideo), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// PopupMenuItem( +// value: 'camera', +// child: ListTile( +// leading: CircleAvatar( +// backgroundColor: +// theme.colorScheme.onPrimaryContainer, +// foregroundColor: theme.colorScheme.primaryContainer, +// child: const Icon(Icons.camera_alt_outlined), +// ), +// title: Text(L10n.of(context).takeAPhoto), +// contentPadding: const EdgeInsets.all(0), +// ), +// ), +// ], +// ), +// ), +// Container( +// height: height, +// width: height, +// alignment: Alignment.center, +// child: IconButton( +// tooltip: L10n.of(context).emojis, +// color: theme.colorScheme.onPrimaryContainer, +// icon: PageTransitionSwitcher( +// transitionBuilder: ( +// Widget child, +// Animation primaryAnimation, +// Animation 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 (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), +// ), +// 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: InputDecoration( +// contentPadding: const EdgeInsets.only( +// left: 6.0, +// right: 6.0, +// bottom: 6.0, +// top: 3.0, +// ), +// hintText: L10n.of(context).writeAMessage, +// hintMaxLines: 1, +// border: InputBorder.none, +// enabledBorder: InputBorder.none, +// filled: false, +// ), +// onChanged: controller.onInputBarChanged, +// ), +// ), +// ), +// Container( +// height: height, +// width: height, +// alignment: Alignment.center, +// child: PlatformInfos.platformCanRecord && +// controller.sendController.text.isEmpty +// ? FloatingActionButton.small( +// tooltip: L10n.of(context).voiceMessage, +// onPressed: controller.voiceMessageAction, +// elevation: 0, +// heroTag: null, +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(height), +// ), +// backgroundColor: theme.bubbleColor, +// foregroundColor: theme.onBubbleColor, +// child: const Icon(Icons.mic_none_outlined), +// ) +// : FloatingActionButton.small( +// tooltip: L10n.of(context).send, +// onPressed: controller.send, +// elevation: 0, +// heroTag: null, +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(height), +// ), +// backgroundColor: theme.bubbleColor, +// foregroundColor: theme.onBubbleColor, +// child: const Icon(Icons.send_outlined), +// ), +// ), +// ], +// ); +// } +// } -class _ChatAccountPicker extends StatelessWidget { - final ChatController controller; +// 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( - future: controller.sendingClient.fetchOwnProfile(), - builder: (context, snapshot) => PopupMenuButton( - useRootNavigator: true, - onSelected: (mxid) => _popupMenuButtonSelected(mxid, context), - itemBuilder: (BuildContext context) => clients - .map( - (client) => PopupMenuItem( - value: client!.userID, - child: FutureBuilder( - 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( +// future: controller.sendingClient.fetchOwnProfile(), +// builder: (context, snapshot) => PopupMenuButton( +// useRootNavigator: true, +// onSelected: (mxid) => _popupMenuButtonSelected(mxid, context), +// itemBuilder: (BuildContext context) => clients +// .map( +// (client) => PopupMenuItem( +// value: client!.userID, +// child: FutureBuilder( +// 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, +// ), +// ), +// ), +// ); +// } +// } diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index c4ab933d3..756b2b384 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -1,16 +1,15 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:emojis/emoji.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:matrix/matrix.dart'; import 'package:slugify/slugify.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; -import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.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/utils/overlay.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; @@ -37,7 +36,7 @@ class InputBar extends StatelessWidget { final Choreographer choreographer; final VoidCallback showNextMatch; // Pangea# - final InputDecoration? decoration; + final InputDecoration decoration; final ValueChanged? onChanged; final bool? autofocus; final bool readOnly; @@ -51,7 +50,7 @@ class InputBar extends StatelessWidget { this.onSubmitImage, this.focusNode, this.controller, - this.decoration, + required this.decoration, this.onChanged, this.autofocus, this.textInputAction, @@ -63,14 +62,12 @@ class InputBar extends StatelessWidget { super.key, }); - List> getSuggestions(String text) { - if (controller!.selection.baseOffset != - controller!.selection.extentOffset || - controller!.selection.baseOffset < 0) { + List> getSuggestions(TextEditingValue text) { + if (text.selection.baseOffset != text.selection.extentOffset || + text.selection.baseOffset < 0) { return []; // no entries if there is selected text } - final searchText = - controller!.text.substring(0, controller!.selection.baseOffset); + final searchText = text.text.substring(0, text.selection.baseOffset); final ret = >[]; const maxResults = 30; @@ -237,36 +234,28 @@ class InputBar extends StatelessWidget { Widget buildSuggestion( BuildContext context, Map suggestion, + void Function(Map) onSelected, Client? client, ) { final theme = Theme.of(context); const size = 30.0; - // #Pangea - // const padding = EdgeInsets.all(4.0); - const padding = EdgeInsets.all(8.0); - // Pangea# if (suggestion['type'] == 'command') { final command = suggestion['name']!; final hint = commandHint(L10n.of(context), command); return Tooltip( message: hint, waitDuration: const Duration(days: 1), // don't show on hover - child: Container( - padding: padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - commandExample(command), - style: const TextStyle(fontFamily: 'RobotoMono'), - ), - Text( - hint, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], + child: ListTile( + onTap: () => onSelected(suggestion), + title: Text( + commandExample(command), + style: const TextStyle(fontFamily: 'RobotoMono'), + ), + subtitle: Text( + hint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, ), ), ); @@ -276,29 +265,28 @@ class InputBar extends StatelessWidget { return Tooltip( message: label, waitDuration: const Duration(days: 1), // don't show on hover - child: Container( - padding: padding, - child: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')), + child: ListTile( + onTap: () => onSelected(suggestion), + title: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')), ), ); } if (suggestion['type'] == 'emote') { - return Container( - padding: padding, - child: Row( + return ListTile( + onTap: () => onSelected(suggestion), + leading: MxcImage( + // ensure proper ordering ... + key: ValueKey(suggestion['name']), + uri: suggestion['mxc'] is String + ? Uri.parse(suggestion['mxc'] ?? '') + : null, + width: size, + height: size, + isThumbnail: false, + ), + title: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - MxcImage( - // ensure proper ordering ... - key: ValueKey(suggestion['name']), - uri: suggestion['mxc'] is String - ? Uri.parse(suggestion['mxc'] ?? '') - : null, - width: size, - height: size, - isThumbnail: false, - ), - const SizedBox(width: 6), Text(suggestion['name']!), Expanded( child: Align( @@ -324,39 +312,30 @@ class InputBar extends StatelessWidget { } if (suggestion['type'] == 'user' || suggestion['type'] == 'room') { final url = Uri.parse(suggestion['avatar_url'] ?? ''); - return Container( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - mxContent: url, - name: suggestion.tryGet('displayname') ?? - suggestion.tryGet('mxid'), - size: size, - client: client, - // #Pangea - userId: suggestion.tryGet('mxid'), - // Pangea# - ), - const SizedBox(width: 6), - // #Pangea - // Text(suggestion['displayname'] ?? suggestion['mxid']!), - Flexible( - child: Text( - suggestion['displayname'] ?? suggestion['mxid']!, - overflow: TextOverflow.ellipsis, - ), - ), - // Pangea# - ], + return ListTile( + onTap: () => onSelected(suggestion), + leading: Avatar( + mxContent: url, + name: suggestion.tryGet('displayname') ?? + suggestion.tryGet('mxid'), + size: size, + client: client, ), + // #Pangea + // title: Text(suggestion['displayname'] ?? suggestion['mxid']!), + title: Flexible( + child: Text( + suggestion['displayname'] ?? suggestion['mxid']!, + overflow: TextOverflow.ellipsis, + ), + ), + // Pangea# ); } return const SizedBox.shrink(); } - void insertSuggestion(_, Map suggestion) { + String insertSuggestion(Map suggestion) { final replaceText = controller!.text.substring(0, controller!.selection.baseOffset); var startText = ''; @@ -420,13 +399,8 @@ class InputBar extends StatelessWidget { (Match m) => '${m[1]}$insertText', ); } - if (insertText.isNotEmpty && startText.isNotEmpty) { - controller!.text = startText + afterText; - controller!.selection = TextSelection( - baseOffset: startText.length, - extentOffset: startText.length, - ); - } + + return startText + afterText; } // #Pangea @@ -446,15 +420,13 @@ class InputBar extends StatelessWidget { void _onInputTap(BuildContext context) { if (_shouldShowPaywall(context)) return; - final baseOffset = controller?.selection.baseOffset; - if (baseOffset == null) return; - + final baseOffset = controller!.selection.baseOffset; final adjustedOffset = _adjustOffsetForNormalization(baseOffset); final match = choreographer.igcController.getMatchByOffset(adjustedOffset); if (match == null) return; if (match.updatedMatch.isITStart) { - choreographer.openIT(match); + choreographer.itController.openIT(controller!.text); } else { OverlayUtil.showIGCMatch( match, @@ -462,6 +434,15 @@ class InputBar extends StatelessWidget { context, showNextMatch, ); + + // rebuild the text field to highlight the newly selected match + choreographer.textController.setSystemText( + choreographer.textController.text, + EditTypeEnum.other, + ); + choreographer.textController.selection = TextSelection.collapsed( + offset: baseOffset, + ); } } @@ -476,7 +457,6 @@ class InputBar extends StatelessWidget { int _adjustOffsetForNormalization(int baseOffset) { int adjustedOffset = baseOffset; final corrections = choreographer.igcController.recentAutomaticCorrections; - if (corrections == null) return adjustedOffset; for (final correction in corrections) { final match = correction.updatedMatch.match; @@ -490,176 +470,102 @@ class InputBar extends StatelessWidget { @override Widget build(BuildContext context) { - // #Pangea - return ValueListenableBuilder( - valueListenable: choreographer.textController, - builder: (context, _, __) { - final enableAutocorrect = MatrixState.pangeaController.userController - .profile.toolSettings.enableAutocorrect; - return TypeAheadField>( - direction: VerticalDirection.up, - hideOnEmpty: true, - hideOnLoading: true, - controller: controller, - focusNode: focusNode, - hideOnSelect: false, - debounceDuration: const Duration(milliseconds: 50), - builder: (context, _, focusNode) { - final textField = TextField( - enableSuggestions: enableAutocorrect, - readOnly: controller!.choreographer.isRunningIT, - autocorrect: enableAutocorrect, - controller: controller, - focusNode: focusNode, - contextMenuBuilder: (c, e) => markdownContextBuilder( - c, - e, - _, - ), - contentInsertionConfiguration: ContentInsertionConfiguration( - onContentInserted: (KeyboardInsertedContent content) { - final data = content.data; - if (data == null) return; + final theme = Theme.of(context); + return Autocomplete>( + focusNode: focusNode, + textEditingController: controller, + optionsBuilder: getSuggestions, + fieldViewBuilder: (context, __, focusNode, _) => TextField( + controller: controller, + focusNode: focusNode, + readOnly: readOnly, + // #Pangea + // contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller), + contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, __), + onTap: () => _onInputTap(context), + // Pangea# + 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, + final file = MatrixFile( + mimeType: content.mimeType, + bytes: data, + name: content.uri.split('/').last, ); - - return Stack( - alignment: Alignment.centerLeft, - children: [ - if (controller != null && controller!.text.isEmpty) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ShrinkableText( - text: controller!.choreographer.itController.open.value - ? L10n.of(context).buildTranslation - : _defaultHintText(context), - maxWidth: double.infinity, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - ), - kIsWeb ? SelectionArea(child: textField) : textField, - ], + room.sendFileEvent( + file, + shrinkImageMaxDimension: 1600, ); }, - suggestionsCallback: getSuggestions, - itemBuilder: (c, s) => - buildSuggestion(c, s, Matrix.of(context).client), - onSelected: (Map 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 + ), + minLines: minLines, + maxLines: maxLines, + keyboardType: keyboardType!, + textInputAction: textInputAction, + autofocus: autofocus!, + inputFormatters: [ + // #Pangea + //LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()), + //setting max character count to 1000 + //after max, nothing else can be typed + LengthLimitingTextInputFormatter(1000), + // Pangea# + ], + onSubmitted: (text) { + // fix for library for now + // it sets the types for the callback incorrectly + onSubmitted!(text); + }, + // #Pangea + // maxLength: AppSettings.textMessageMaxLength.value, + // decoration: decoration!, + // Pangea# + decoration: decoration.copyWith( + hint: ValueListenableBuilder( + valueListenable: choreographer.itController.open, + builder: (context, _, __) { + return ShrinkableText( + text: choreographer.itController.open.value + ? L10n.of(context).buildTranslation + : _defaultHintText(context), + maxWidth: double.infinity, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).disabledColor, + ), + ); + }, + ), + ), + onChanged: (text) { + // fix for the library for now + // it sets the types for the callback incorrectly + onChanged!(text); + }, + textCapitalization: TextCapitalization.sentences, + ), + optionsViewBuilder: (c, onSelected, s) { + final suggestions = s.toList(); + return Material( + elevation: theme.appBarTheme.scrolledUnderElevation ?? 4, + shadowColor: theme.appBarTheme.shadowColor, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListView.builder( + shrinkWrap: true, + itemCount: suggestions.length, + itemBuilder: (context, i) => buildSuggestion( + c, + suggestions[i], + onSelected, + Matrix.of(context).client, + ), + ), ); }, + displayStringForOption: insertSuggestion, + optionsViewOpenDirection: OptionsViewOpenDirection.up, ); - // return TypeAheadField>( - // 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) - // 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); - // }, - // maxLength: - // AppSettings.textMessageMaxLength.getItem(Matrix.of(context).store), - // decoration: decoration, - // onChanged: (text) { - // // fix for the library for now - // // it sets the types for the callback incorrectly - // onChanged!(text); - // }, - // textCapitalization: TextCapitalization.sentences, - // ), - - // suggestionsCallback: getSuggestions, - // itemBuilder: (c, s) => buildSuggestion(c, s, Matrix.of(context).client), - // onSelected: (Map 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 - // ); - // Pangea# } } diff --git a/lib/pangea/chat/widgets/pangea_chat_input_row.dart b/lib/pangea/chat/widgets/pangea_chat_input_row.dart index f0d466d01..36261bee6 100644 --- a/lib/pangea/chat/widgets/pangea_chat_input_row.dart +++ b/lib/pangea/chat/widgets/pangea_chat_input_row.dart @@ -210,7 +210,10 @@ class PangeaChatInputRow extends StatelessWidget { ), ), StartIGCButton( - controller: controller, + key: ValueKey(controller.choreographer), + onPressed: () => + controller.onRequestWritingAssistance(manual: true), + choreographer: controller.choreographer, initialState: state, initialForegroundColor: state.stateColor(context), initialBackgroundColor: state.backgroundColor(context), diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 2fbe56923..9f164d35d 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -10,10 +10,10 @@ 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/it/completed_it_step_model.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/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; @@ -43,8 +43,10 @@ class Choreographer extends ChangeNotifier { String? _lastChecked; ChoreoModeEnum _choreoMode = ChoreoModeEnum.igc; - StreamSubscription? _languageStream; - StreamSubscription? _settingsUpdateStream; + StreamSubscription? _languageSub; + StreamSubscription? _settingsUpdateSub; + StreamSubscription? _acceptedContinuanceSub; + StreamSubscription? _updatedMatchSub; Choreographer( this.inputFocus, @@ -73,23 +75,30 @@ class Choreographer extends ChangeNotifier { itController = ITController( (e) => errorService.setErrorAndLock(ChoreoError(raw: e)), ); - itController.open.addListener(_onCloseIT); + itController.open.addListener(_onUpdateITOpenStatus); + itController.editing.addListener(_onSubmitSourceTextEdits); igcController = IgcController( (e) => errorService.setErrorAndLock(ChoreoError(raw: e)), ); - _languageStream ??= MatrixState + _languageSub ??= MatrixState .pangeaController.userController.languageStream.stream .listen((update) { clear(); }); - _settingsUpdateStream ??= MatrixState + _settingsUpdateSub ??= MatrixState .pangeaController.userController.settingsUpdateStream.stream .listen((_) { notifyListeners(); }); + + _acceptedContinuanceSub ??= itController.acceptedContinuanceStream.stream + .listen(_onAcceptContinuance); + + _updatedMatchSub ??= + igcController.matchUpdateStream.stream.listen(_onUpdateMatch); } void clear() { @@ -97,7 +106,7 @@ class Choreographer extends ChangeNotifier { _timesClicked = 0; _isFetching.value = false; _choreoRecord = null; - itController.clear(); + itController.closeIT(); itController.clearSourceText(); igcController.clear(); _resetDebounceTimer(); @@ -108,14 +117,21 @@ class Choreographer extends ChangeNotifier { void dispose() { errorService.removeListener(notifyListeners); itController.open.removeListener(_onCloseIT); + itController.editing.removeListener(_onSubmitSourceTextEdits); textController.removeListener(_onChange); + + _languageSub?.cancel(); + _settingsUpdateSub?.cancel(); + _acceptedContinuanceSub?.cancel(); + _updatedMatchSub?.cancel(); + _debounceTimer?.cancel(); + + igcController.dispose(); itController.dispose(); errorService.dispose(); textController.dispose(); - _languageStream?.cancel(); - _settingsUpdateStream?.cancel(); - _debounceTimer?.cancel(); _isFetching.dispose(); + TtsController.stop(); super.dispose(); } @@ -174,7 +190,7 @@ class Choreographer extends ChangeNotifier { _lastChecked = textController.text; if (errorService.isError) return; if (textController.editType == EditTypeEnum.keyboard) { - if (igcController.hasIGCTextData || + if (igcController.currentText != null || itController.sourceText.value != null) { igcController.clear(); itController.clearSourceText(); @@ -184,16 +200,13 @@ class Choreographer extends ChangeNotifier { _resetDebounceTimer(); _debounceTimer ??= Timer( const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart), - () => _getWritingAssistance(), + () => requestWritingAssistance(), ); } textController.editType = EditTypeEnum.keyboard; } - Future requestWritingAssistance({bool manual = false}) => - _getWritingAssistance(manual: manual); - - Future _getWritingAssistance({ + Future requestWritingAssistance({ bool manual = false, }) async { if (assistanceState != AssistanceStateEnum.notFetched) return; @@ -213,19 +226,27 @@ class Choreographer extends ChangeNotifier { _resetDebounceTimer(); _startLoading(); + await igcController.getIGCTextData( textController.text, [], ); - _acceptNormalizationMatches(); - // trigger a re-render of the text field to show IGC matches - textController.setSystemText( - textController.text, - EditTypeEnum.igc, - ); - _stopLoading(); - igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e)); + if (igcController.openAutomaticMatches.isNotEmpty) { + await igcController.acceptNormalizationMatches(); + } else { + // trigger a re-render of the text field to show IGC matches + textController.setSystemText( + textController.text, + EditTypeEnum.igc, + ); + } + + _stopLoading(); + if (!igcController.openMatches + .any((match) => match.updatedMatch.isITStart)) { + igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e)); + } } Future getMessageContent(String message) async { @@ -279,27 +300,30 @@ class Choreographer extends ChangeNotifier { ); } - void openIT(PangeaMatchState itMatch) { - if (!itMatch.updatedMatch.isITStart) { - throw Exception("Attempted to open IT with a non-IT start match"); - } + void _onUpdateITOpenStatus() { + itController.open.value ? _onOpenIT() : _onCloseIT(); + notifyListeners(); + } + + void _onOpenIT() { + final itMatch = igcController.openMatches.firstWhere( + (match) => match.updatedMatch.isITStart, + orElse: () => + throw Exception("Attempted to open IT without an ITStart match"), + ); - _setChoreoMode(ChoreoModeEnum.it); - final sourceText = currentText; - textController.setSystemText("", EditTypeEnum.it); - itController.openIT(sourceText); igcController.clear(); - itMatch.setStatus(PangeaMatchStatusEnum.accepted); _record.addRecord( "", match: itMatch.updatedMatch, ); - notifyListeners(); + + _setChoreoMode(ChoreoModeEnum.it); + textController.setSystemText("", EditTypeEnum.it); } void _onCloseIT() { - if (itController.open.value) return; if (currentText.isEmpty && itController.sourceText.value != null) { textController.setSystemText( itController.sourceText.value!, @@ -309,16 +333,14 @@ class Choreographer extends ChangeNotifier { _setChoreoMode(ChoreoModeEnum.igc); errorService.resetError(); - notifyListeners(); } - void onSubmitEdits(String text) { + void _onSubmitSourceTextEdits() { + if (itController.editing.value) return; textController.setSystemText("", EditTypeEnum.it); - itController.onSubmitEdits(text); } - void onAcceptContinuance(int index) { - final step = itController.onAcceptContinuance(index); + void _onAcceptContinuance(CompletedITStepModel step) { textController.setSystemText( textController.text + step.continuances[step.chosen].text, EditTypeEnum.it, @@ -335,93 +357,30 @@ class Choreographer extends ChangeNotifier { errorService.setError(ChoreoError(raw: error)); } - void onAcceptReplacement({ - required PangeaMatchState match, - }) { - final updatedMatch = igcController.acceptReplacement( - match, - PangeaMatchStatusEnum.accepted, - ); - + void _onUpdateMatch(PangeaMatchState match) { textController.setSystemText( igcController.currentText!, EditTypeEnum.igc, ); - if (!updatedMatch.match.isNormalizationError()) { - _record.addRecord( - textController.text, - match: updatedMatch, - ); - } - MatrixState.pAnyState.closeOverlay(); - inputFocus.requestFocus(); - notifyListeners(); - } - - void onUndoReplacement(PangeaMatchState match) { - igcController.undoReplacement(match); - _record.choreoSteps.removeWhere( - (step) => step.acceptedOrIgnoredMatch == match.updatedMatch, - ); - - textController.setSystemText( - igcController.currentText!, - EditTypeEnum.igc, - ); - MatrixState.pAnyState.closeOverlay(); - inputFocus.requestFocus(); - notifyListeners(); - } - - void onIgnoreReplacement({required PangeaMatchState match}) { - final updatedMatch = igcController.ignoreReplacement(match); - if (!updatedMatch.match.isNormalizationError()) { - _record.addRecord( - textController.text, - match: updatedMatch, - ); - } - MatrixState.pAnyState.closeOverlay(); - inputFocus.requestFocus(); - notifyListeners(); - } - - void _acceptNormalizationMatches() { - final normalizationsMatches = igcController.openNormalizationMatches; - if (normalizationsMatches?.isEmpty ?? true) return; - - try { - for (final match in normalizationsMatches!) { - match.selectChoice( - match.updatedMatch.match.choices!.indexWhere( - (c) => c.isBestCorrection, - ), - ); - final updatedMatch = igcController.acceptReplacement( - match, - PangeaMatchStatusEnum.automatic, - ); - - textController.setSystemText( - igcController.currentText!, - EditTypeEnum.igc, - ); + switch (match.updatedMatch.status) { + case PangeaMatchStatusEnum.accepted: + case PangeaMatchStatusEnum.automatic: + case PangeaMatchStatusEnum.ignored: _record.addRecord( - currentText, - match: updatedMatch, + textController.text, + match: match.updatedMatch, ); - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "currentText": currentText, - "choreoRecord": _record.toJson(), - }, - ); + case PangeaMatchStatusEnum.undo: + _record.choreoSteps.removeWhere( + (step) => + step.acceptedOrIgnoredMatch?.match == match.updatedMatch.match, + ); + default: + throw Exception("Unhandled match status: ${match.updatedMatch.status}"); } + + inputFocus.requestFocus(); notifyListeners(); } } diff --git a/lib/pangea/choreographer/choreographer_state_extension.dart b/lib/pangea/choreographer/choreographer_state_extension.dart index 58f2adf8f..ec1d269ce 100644 --- a/lib/pangea/choreographer/choreographer_state_extension.dart +++ b/lib/pangea/choreographer/choreographer_state_extension.dart @@ -21,12 +21,12 @@ extension ChoregrapherUserSettingsExtension on Choreographer { return AssistanceStateEnum.error; } - if (igcController.hasOpenMatches || isRunningIT) { + if (igcController.openMatches.isNotEmpty || isRunningIT) { return AssistanceStateEnum.fetched; } if (isFetching.value) return AssistanceStateEnum.fetching; - if (!igcController.hasIGCTextData && + if (igcController.currentText == null && itController.sourceText.value == null) { return AssistanceStateEnum.notFetched; } diff --git a/lib/pangea/choreographer/igc/igc_controller.dart b/lib/pangea/choreographer/igc/igc_controller.dart index e38aa8b67..91766390b 100644 --- a/lib/pangea/choreographer/igc/igc_controller.dart +++ b/lib/pangea/choreographer/igc/igc_controller.dart @@ -1,75 +1,300 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:async/async.dart'; +import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart'; -import 'package:fluffychat/pangea/choreographer/igc/igc_text_data_model.dart'; -import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.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/igc/span_data_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_repo.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_request.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class IgcController { final Function(Object) onError; - - bool _isFetching = false; - IGCTextData? _igcTextData; - IgcController(this.onError); - String? get currentText => _igcTextData?.currentText; - bool get hasOpenMatches => _igcTextData?.hasOpenMatches == true; + bool _isFetching = false; + String? _currentText; - PangeaMatchState? get currentlyOpenMatch => _igcTextData?.currentlyOpenMatch; - PangeaMatchState? get firstOpenMatch => _igcTextData?.firstOpenMatch; - List? get openMatches => _igcTextData?.openMatches; - List? get recentAutomaticCorrections => - _igcTextData?.recentAutomaticCorrections; - List? get openNormalizationMatches => - _igcTextData?.openNormalizationMatches; + final List _openMatches = []; + final List _closedMatches = []; - bool get canShowFirstMatch => _igcTextData?.firstOpenMatch != null; - bool get hasIGCTextData => _igcTextData != null; + StreamController matchUpdateStream = + StreamController.broadcast(); + + String? get currentText => _currentText; + List get openMatches => _openMatches; + + List get recentAutomaticCorrections => + _closedMatches.reversed + .takeWhile( + (m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic, + ) + .toList(); + + List get openAutomaticMatches => _openMatches + .where((match) => match.updatedMatch.match.isNormalizationError()) + .toList(); + + PangeaMatchState? get currentlyOpenMatch { + final RegExp pattern = RegExp(r'span_card_overlay_.+'); + final String? matchingKey = + MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull; + if (matchingKey == null) return null; + + final parts = matchingKey.split('_'); + if (parts.length != 5) return null; + final offset = int.tryParse(parts[3]); + final length = int.tryParse(parts[4]); + if (offset == null || length == null) return null; + + return _openMatches.firstWhereOrNull( + (match) => + match.updatedMatch.match.offset == offset && + match.updatedMatch.match.length == length, + ); + } + + IGCRequestModel _igcRequest( + String text, + List prevMessages, + ) => + IGCRequestModel( + fullText: text, + userId: MatrixState.pangeaController.userController.userId!, + userL1: MatrixState.pangeaController.languageController.activeL1Code()!, + userL2: MatrixState.pangeaController.languageController.activeL2Code()!, + enableIGC: true, + enableIT: true, + prevMessages: prevMessages, + ); + + SpanDetailsRequest _spanDetailsRequest(SpanData span) => SpanDetailsRequest( + userL1: MatrixState.pangeaController.languageController.activeL1Code()!, + userL2: MatrixState.pangeaController.languageController.activeL2Code()!, + enableIGC: true, + enableIT: true, + span: span, + ); + + void dispose() { + matchUpdateStream.close(); + } void clear() { _isFetching = false; - _igcTextData = null; + _currentText = null; + _openMatches.clear(); + _closedMatches.clear(); MatrixState.pAnyState.closeAllOverlays(); } - void clearMatches() => _igcTextData?.clearMatches(); + void clearMatches() { + _openMatches.clear(); + _closedMatches.clear(); + } + + void _filterPreviouslyIgnoredMatches() { + for (final match in _openMatches) { + if (IgcRepo.isIgnored(match.updatedMatch)) { + updateOpenMatch(match, PangeaMatchStatusEnum.ignored); + } + } + } PangeaMatchState? getMatchByOffset(int offset) => - _igcTextData?.getOpenMatchByOffset(offset); + _openMatches.firstWhereOrNull( + (match) => match.updatedMatch.match.isOffsetInMatchSpan(offset), + ); - PangeaMatch acceptReplacement( + void setSpanData(PangeaMatchState matchState, SpanData spanData) { + final openMatch = _openMatches.firstWhereOrNull( + (m) => m.originalMatch == matchState.originalMatch, + ); + + matchState.setMatch(spanData); + _openMatches.remove(openMatch); + _openMatches.add(matchState); + } + + void updateMatch( PangeaMatchState match, PangeaMatchStatusEnum status, ) { - if (_igcTextData == null) { - throw "acceptReplacement called with null igcTextData"; + PangeaMatchState updated; + switch (status) { + case PangeaMatchStatusEnum.accepted: + case PangeaMatchStatusEnum.automatic: + updated = updateOpenMatch(match, status); + case PangeaMatchStatusEnum.ignored: + IgcRepo.ignore(match.updatedMatch); + updated = updateOpenMatch(match, status); + case PangeaMatchStatusEnum.undo: + updated = updateClosedMatch(match, status); + default: + throw "updateMatch called with unsupported status: $status"; } - final updateMatch = _igcTextData!.acceptMatch(match, status); - return updateMatch; + matchUpdateStream.add(updated); } - PangeaMatch ignoreReplacement(PangeaMatchState match) { - IgcRepo.ignore(match.updatedMatch); - if (_igcTextData == null) { - throw "should not be in onIgnoreMatch with null igcTextData"; + PangeaMatchState updateOpenMatch( + PangeaMatchState matchState, + PangeaMatchStatusEnum status, + ) { + final PangeaMatchState openMatch = _openMatches.firstWhere( + (m) => m.originalMatch == matchState.originalMatch, + orElse: () => throw StateError( + 'No open match found while updating match.', + ), + ); + + matchState.setStatus(status); + _openMatches.remove(openMatch); + _closedMatches.add(matchState); + + switch (status) { + case PangeaMatchStatusEnum.accepted: + case PangeaMatchStatusEnum.automatic: + final choice = matchState.updatedMatch.match.selectedChoice; + if (choice == null) { + throw ArgumentError( + 'acceptMatch called with a null selectedChoice.', + ); + } + _applyReplacement( + matchState.updatedMatch.match.offset, + matchState.updatedMatch.match.length, + choice.value, + ); + case PangeaMatchStatusEnum.ignored: + break; + default: + throw ArgumentError( + 'updateOpenMatch called with unsupported status: $status', + ); } - return _igcTextData!.ignoreMatch(match); + + return matchState; } - void undoReplacement(PangeaMatchState match) { - if (_igcTextData == null) { - throw "undoReplacement called with null igcTextData"; + PangeaMatchState updateClosedMatch( + PangeaMatchState matchState, + PangeaMatchStatusEnum status, + ) { + final closedMatch = _closedMatches.firstWhere( + (m) => m.originalMatch == matchState.originalMatch, + orElse: () => throw StateError( + 'No closed match found while updating match.', + ), + ); + + matchState.setStatus(status); + _closedMatches.remove(closedMatch); + + final selectedValue = matchState.updatedMatch.match.selectedChoice?.value; + if (selectedValue == null) { + throw StateError( + 'Cannot update match without a selectedChoice value.', + ); + } + + final replacement = matchState.originalMatch.match.fullText.characters + .getRange( + matchState.originalMatch.match.offset, + matchState.originalMatch.match.offset + + matchState.originalMatch.match.length, + ) + .toString(); + + _applyReplacement( + matchState.originalMatch.match.offset, + selectedValue.characters.length, + replacement, + ); + + return matchState; + } + + Future acceptNormalizationMatches() async { + final matches = openAutomaticMatches; + if (matches.isEmpty) return; + + final expectedSpans = matches.map((m) => m.originalMatch).toSet(); + final completer = Completer(); + + int completedCount = 0; + + late final StreamSubscription sub; + sub = matchUpdateStream.stream.listen((match) { + if (expectedSpans.remove(match.originalMatch)) { + completedCount++; + if (completedCount >= matches.length) { + completer.complete(); + sub.cancel(); + } + } + }); + + try { + for (final match in matches) { + match.selectBestChoice(); + updateMatch(match, PangeaMatchStatusEnum.automatic); + } + + // If no updates arrive (edge case), auto-timeout after a short delay + Future.delayed(const Duration(seconds: 1), () { + if (!completer.isCompleted) { + completer.complete(); + sub.cancel(); + } + }); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {"currentText": currentText}, + ); + if (!completer.isCompleted) completer.complete(); + } + + return completer.future; + } + + /// Applies a text replacement to [_currentText] and adjusts match offsets. + /// + /// Called internally when a correction is accepted or undone. + void _applyReplacement( + int offset, + int length, + String replacement, + ) { + if (_currentText == null) { + throw StateError('_applyReplacement called with null _currentText'); + } + final start = _currentText!.characters.take(offset); + final end = _currentText!.characters.skip(offset + length); + final updatedText = start + replacement.characters + end; + _currentText = updatedText.toString(); + + for (final list in [_openMatches, _closedMatches]) { + for (final matchState in list) { + final match = matchState.updatedMatch.match; + final updatedMatch = match.copyWith( + fullText: _currentText, + offset: match.offset > offset + ? match.offset + replacement.characters.length - length + : match.offset, + ); + matchState.setMatch(updatedMatch); + } } - _igcTextData!.undoMatch(match); } Future getIGCTextData( @@ -79,19 +304,10 @@ class IgcController { if (text.isEmpty) return clear(); if (_isFetching) return; _isFetching = true; - final IGCRequestModel reqBody = IGCRequestModel( - fullText: text, - userId: MatrixState.pangeaController.userController.userId!, - userL1: MatrixState.pangeaController.languageController.activeL1Code()!, - userL2: MatrixState.pangeaController.languageController.activeL2Code()!, - enableIGC: true, - enableIT: true, - prevMessages: prevMessages, - ); final res = await IgcRepo.get( MatrixState.pangeaController.userController.accessToken, - reqBody, + _igcRequest(text, prevMessages), ).timeout( (const Duration(seconds: 10)), onTimeout: () { @@ -108,7 +324,20 @@ class IgcController { } if (!_isFetching) return; - _igcTextData = res.result!; + _currentText = res.result!.originalInput; + for (final match in res.result!.matches) { + final matchState = PangeaMatchState( + match: match.match, + status: PangeaMatchStatusEnum.open, + original: match, + ); + if (match.status == PangeaMatchStatusEnum.open) { + _openMatches.add(matchState); + } else { + _closedMatches.add(matchState); + } + } + _filterPreviouslyIgnoredMatches(); _isFetching = false; } @@ -123,13 +352,7 @@ class IgcController { final response = await SpanDataRepo.get( MatrixState.pangeaController.userController.accessToken, - request: SpanDetailsRequest( - userL1: MatrixState.pangeaController.languageController.activeL1Code()!, - userL2: MatrixState.pangeaController.languageController.activeL2Code()!, - enableIGC: true, - enableIT: true, - span: span, - ), + request: _spanDetailsRequest(span), ).timeout( (const Duration(seconds: 10)), onTimeout: () { @@ -139,17 +362,13 @@ class IgcController { }, ); - if (response.isError) { - throw response.error!; - } - - _igcTextData?.setSpanData(match, response.result!); + if (response.isError) throw response.error!; + setSpanData(match, response.result!); } Future fetchAllSpanDetails() async { - if (_igcTextData == null) return; final fetches = []; - for (final match in _igcTextData!.openMatches) { + for (final match in _openMatches) { fetches.add(fetchSpanDetails(match: match)); } await Future.wait(fetches); diff --git a/lib/pangea/choreographer/igc/igc_repo.dart b/lib/pangea/choreographer/igc/igc_repo.dart index 3dcf77205..3ffbc5d07 100644 --- a/lib/pangea/choreographer/igc/igc_repo.dart +++ b/lib/pangea/choreographer/igc/igc_repo.dart @@ -7,7 +7,6 @@ import 'package:http/http.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; -import 'package:fluffychat/pangea/choreographer/igc/igc_text_data_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -15,7 +14,7 @@ import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; class _IgcCacheItem { - final Future data; + final Future data; final DateTime timestamp; const _IgcCacheItem({ @@ -53,7 +52,7 @@ class IgcRepo { static final Map _ignoredMatchCache = {}; static const Duration _cacheDuration = Duration(minutes: 10); - static Future> get( + static Future> get( String? accessToken, IGCRequestModel igcRequest, ) { @@ -70,7 +69,7 @@ class IgcRepo { return _getResult(igcRequest, future); } - static Future _fetch( + static Future _fetch( String? accessToken, { required IGCRequestModel igcRequest, }) async { @@ -92,16 +91,12 @@ class IgcRepo { final Map json = jsonDecode(utf8.decode(res.bodyBytes).toString()); - final respModel = IGCResponseModel.fromJson(json); - return IGCTextData( - originalInput: respModel.originalInput, - matches: respModel.matches, - ); + return IGCResponseModel.fromJson(json); } - static Future> _getResult( + static Future> _getResult( IGCRequestModel request, - Future future, + Future future, ) async { try { final res = await future; @@ -117,7 +112,7 @@ class IgcRepo { } } - static Future? _getCached( + static Future? _getCached( IGCRequestModel request, ) { final cacheKeys = [..._igcCache.keys]; @@ -134,7 +129,7 @@ class IgcRepo { static void _setCached( IGCRequestModel request, - Future response, + Future response, ) => _igcCache[request.hashCode.toString()] = _IgcCacheItem( data: response, diff --git a/lib/pangea/choreographer/igc/igc_text_data_model.dart b/lib/pangea/choreographer/igc/igc_text_data_model.dart deleted file mode 100644 index 52352f951..000000000 --- a/lib/pangea/choreographer/igc/igc_text_data_model.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart'; -import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.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/igc/span_data_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -/// A model representing mutable text and match state used by -/// Interactive Grammar Correction (IGC). -/// -/// This class tracks the user's original text, the current working text, -/// and the states of grammar matches detected during processing. -/// It provides methods to accept, ignore, or undo corrections, while -/// maintaining consistent text and offset updates across all matches. -class IGCTextData { - /// The user's original text before any corrections or replacements. - final String _originalText; - - /// The complete list of detected matches from the initial grammar analysis. - final List _initialMatches; - - /// Matches currently awaiting user action (neither accepted nor ignored). - final List _openMatches = []; - - /// Matches that have been resolved, either accepted or ignored. - final List _closedMatches = []; - - /// The current working text after applying accepted corrections. - String _currentText; - - /// Creates a new instance of [IGCTextData] from the given [originalInput] - /// and list of grammar [matches]. - /// - /// Automatically initializes open and closed matches based on their status - /// and filters out previously ignored matches. - IGCTextData({ - required String originalInput, - required List matches, - }) : _originalText = originalInput, - _currentText = originalInput, - _initialMatches = matches { - for (final match in matches) { - final matchState = PangeaMatchState( - match: match.match, - status: PangeaMatchStatusEnum.open, - original: match, - ); - if (match.status == PangeaMatchStatusEnum.open) { - _openMatches.add(matchState); - } else { - _closedMatches.add(matchState); - } - } - _filterPreviouslyIgnoredMatches(); - } - - /// Returns a JSON representation of this IGC text data. - Map toJson() => { - 'original_input': _originalText, - 'matches': _initialMatches.map((e) => e.toJson()).toList(), - }; - - /// The current working text after any accepted replacements. - String get currentText => _currentText; - - /// The list of open matches that are still awaiting user action. - List get openMatches => List.unmodifiable(_openMatches); - - /// Whether there are any open matches remaining. - bool get hasOpenMatches => _openMatches.isNotEmpty; - - /// The first open match, if one exists. - PangeaMatchState? get firstOpenMatch => _openMatches.firstOrNull; - - /// Closed matches that were automatically corrected in recent steps. - /// - /// Used to display automatic normalization corrections applied - /// by the IGC system. - List get recentAutomaticCorrections => - _closedMatches.reversed - .takeWhile( - (m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic, - ) - .toList(); - - /// Open matches representing normalization errors that can be auto-corrected. - List get openNormalizationMatches => _openMatches - .where((match) => match.updatedMatch.match.isNormalizationError()) - .toList(); - - /// Returns the open match that contains the given text [offset], if any. - PangeaMatchState? getOpenMatchByOffset(int offset) => - _openMatches.firstWhereOrNull( - (match) => match.updatedMatch.match.isOffsetInMatchSpan(offset), - ); - - /// Returns the match whose span card overlay is currently open, if any. - PangeaMatchState? get currentlyOpenMatch { - final RegExp pattern = RegExp(r'span_card_overlay_.+'); - final String? matchingKey = - MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull; - if (matchingKey == null) return null; - - final parts = matchingKey.split('_'); - if (parts.length != 5) return null; - final offset = int.tryParse(parts[3]); - final length = int.tryParse(parts[4]); - if (offset == null || length == null) return null; - - return _openMatches.firstWhereOrNull( - (match) => - match.updatedMatch.match.offset == offset && - match.updatedMatch.match.length == length, - ); - } - - /// Clears all match data from this IGC instance. - /// - /// Call this when an error occurs that invalidates current match data. - void clearMatches() { - _openMatches.clear(); - _closedMatches.clear(); - } - - /// Filters out any previously ignored matches from the open list. - void _filterPreviouslyIgnoredMatches() { - for (final match in _openMatches) { - if (IgcRepo.isIgnored(match.updatedMatch)) { - ignoreMatch(match); - } - } - } - - /// Updates the [matchState] with new [spanData]. - /// - /// Replaces the existing span information for the given match - /// while maintaining its position in the open list. - void setSpanData(PangeaMatchState matchState, SpanData spanData) { - final openMatch = _openMatches.firstWhereOrNull( - (m) => m.originalMatch == matchState.originalMatch, - ); - - matchState.setMatch(spanData); - _openMatches.remove(openMatch); - _openMatches.add(matchState); - } - - /// Accepts the given [matchState], updates text and state lists, - /// and returns the updated [PangeaMatch]. - /// - /// Applies the selected replacement text to [_currentText] and - /// updates offsets for all matches accordingly. - PangeaMatch acceptMatch( - PangeaMatchState matchState, - PangeaMatchStatusEnum status, - ) { - final openMatch = _openMatches.firstWhere( - (m) => m.originalMatch == matchState.originalMatch, - orElse: () => throw StateError( - 'No open match found while accepting match.', - ), - ); - - final choice = matchState.updatedMatch.match.selectedChoice; - if (choice == null) { - throw ArgumentError( - 'acceptMatch called with a null selectedChoice.', - ); - } - - matchState.setStatus(status); - _openMatches.remove(openMatch); - _closedMatches.add(matchState); - - _applyReplacement( - matchState.updatedMatch.match.offset, - matchState.updatedMatch.match.length, - choice.value, - ); - - return matchState.updatedMatch; - } - - /// Ignores the given [matchState] and moves it to the closed match list. - /// - /// Returns the updated [PangeaMatch] after applying the ignore operation. - PangeaMatch ignoreMatch(PangeaMatchState matchState) { - final openMatch = _openMatches.firstWhere( - (m) => m.originalMatch == matchState.originalMatch, - orElse: () => throw StateError( - 'No open match found while ignoring match.', - ), - ); - - matchState.setStatus(PangeaMatchStatusEnum.ignored); - _openMatches.remove(openMatch); - _closedMatches.add(matchState); - return matchState.updatedMatch; - } - - /// Undoes a previously accepted match by reverting the replacement - /// and removing it from the closed match list. - void undoMatch(PangeaMatchState matchState) { - final closedMatch = _closedMatches.firstWhere( - (m) => m.originalMatch == matchState.originalMatch, - orElse: () => throw StateError( - 'No closed match found while undoing match.', - ), - ); - - _closedMatches.remove(closedMatch); - - final selectedValue = matchState.updatedMatch.match.selectedChoice?.value; - if (selectedValue == null) { - throw StateError( - 'Cannot undo match without a selectedChoice value.', - ); - } - - final replacement = matchState.originalMatch.match.fullText.characters - .getRange( - matchState.originalMatch.match.offset, - matchState.originalMatch.match.offset + - matchState.originalMatch.match.length, - ) - .toString(); - - _applyReplacement( - matchState.originalMatch.match.offset, - selectedValue.characters.length, - replacement, - ); - } - - /// Applies a text replacement to [_currentText] and adjusts match offsets. - /// - /// Called internally when a correction is accepted or undone. - void _applyReplacement( - int offset, - int length, - String replacement, - ) { - final start = _currentText.characters.take(offset); - final end = _currentText.characters.skip(offset + length); - final updatedText = start + replacement.characters + end; - _currentText = updatedText.toString(); - - for (final list in [_openMatches, _closedMatches]) { - for (final matchState in list) { - final match = matchState.updatedMatch.match; - final updatedMatch = match.copyWith( - fullText: _currentText, - offset: match.offset > offset - ? match.offset + replacement.characters.length - length - : match.offset, - ); - matchState.setMatch(updatedMatch); - } - } - } -} diff --git a/lib/pangea/choreographer/igc/pangea_match_state_model.dart b/lib/pangea/choreographer/igc/pangea_match_state_model.dart index 9ad5d6e4e..7fb0fcc7d 100644 --- a/lib/pangea/choreographer/igc/pangea_match_state_model.dart +++ b/lib/pangea/choreographer/igc/pangea_match_state_model.dart @@ -39,6 +39,17 @@ class PangeaMatchState { setMatch(_match.copyWith(choices: choices)); } + void selectBestChoice() { + if (_match.choices == null) { + throw Exception('No choices available to select best choice from.'); + } + selectChoice( + updatedMatch.match.choices!.indexWhere( + (c) => c.isBestCorrection, + ), + ); + } + Map toJson() { return { 'originalMatch': _original.toJson(), diff --git a/lib/pangea/choreographer/igc/pangea_match_status_enum.dart b/lib/pangea/choreographer/igc/pangea_match_status_enum.dart index 3257b8f6f..967ab81e9 100644 --- a/lib/pangea/choreographer/igc/pangea_match_status_enum.dart +++ b/lib/pangea/choreographer/igc/pangea_match_status_enum.dart @@ -3,6 +3,7 @@ enum PangeaMatchStatusEnum { ignored, accepted, automatic, + undo, unknown; static PangeaMatchStatusEnum fromString(String status) { @@ -16,6 +17,8 @@ enum PangeaMatchStatusEnum { return PangeaMatchStatusEnum.accepted; case 'automatic': return PangeaMatchStatusEnum.automatic; + case 'undo': + return PangeaMatchStatusEnum.undo; default: return PangeaMatchStatusEnum.unknown; } diff --git a/lib/pangea/choreographer/igc/span_card.dart b/lib/pangea/choreographer/igc/span_card.dart index a1f0412a9..0dfb84374 100644 --- a/lib/pangea/choreographer/igc/span_card.dart +++ b/lib/pangea/choreographer/igc/span_card.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.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/igc/span_choice_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; @@ -123,9 +124,12 @@ class SpanCardState extends State { setState(() {}); } - void _onMatchUpdate(VoidCallback updateFunc) async { + void _updateMatch(PangeaMatchStatusEnum status) { try { - updateFunc(); + widget.choreographer.igcController.updateMatch( + widget.match, + status, + ); widget.showNextMatch(); } catch (e, s) { ErrorHandler.logError( @@ -141,14 +145,6 @@ class SpanCardState extends State { } } - void _onAcceptReplacement() => _onMatchUpdate(() { - widget.choreographer.onAcceptReplacement(match: widget.match); - }); - - void _onIgnoreMatch() => _onMatchUpdate(() { - widget.choreographer.onIgnoreReplacement(match: widget.match); - }); - @override Widget build(BuildContext context) { return SizedBox( @@ -196,8 +192,8 @@ class SpanCardState extends State { ), ), _SpanCardButtons( - onAccept: _onAcceptReplacement, - onIgnore: _onIgnoreMatch, + onAccept: () => _updateMatch(PangeaMatchStatusEnum.accepted), + onIgnore: () => _updateMatch(PangeaMatchStatusEnum.ignored), selectedChoice: _selectedChoice, ), ], diff --git a/lib/pangea/choreographer/igc/start_igc_button.dart b/lib/pangea/choreographer/igc/start_igc_button.dart index 111a440f4..ffcb3ea6b 100644 --- a/lib/pangea/choreographer/igc/start_igc_button.dart +++ b/lib/pangea/choreographer/igc/start_igc_button.dart @@ -2,19 +2,21 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart'; +import 'package:fluffychat/pangea/choreographer/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; -import '../../../pages/chat/chat.dart'; class StartIGCButton extends StatefulWidget { - final ChatController controller; + final VoidCallback onPressed; + final Choreographer choreographer; final AssistanceStateEnum initialState; final Color initialForegroundColor; final Color initialBackgroundColor; const StartIGCButton({ super.key, - required this.controller, + required this.onPressed, + required this.choreographer, required this.initialState, required this.initialForegroundColor, required this.initialBackgroundColor, @@ -34,9 +36,6 @@ class _StartIGCButtonState extends State late Animation _backgroundColor; AssistanceStateEnum? _prevState; - AssistanceStateEnum get state => - widget.controller.choreographer.assistanceState; - bool _shouldStop = false; @override @@ -73,12 +72,12 @@ class _StartIGCButtonState extends State _backgroundColor = AlwaysStoppedAnimation(widget.initialBackgroundColor); _colorController!.forward(from: 0.0); - widget.controller.choreographer.addListener(_handleStateChange); + widget.choreographer.addListener(_handleStateChange); } @override void dispose() { - widget.controller.choreographer.removeListener(_handleStateChange); + widget.choreographer.removeListener(_handleStateChange); _spinController?.dispose(); _colorController?.dispose(); super.dispose(); @@ -86,7 +85,7 @@ class _StartIGCButtonState extends State void _handleStateChange() { final prev = _prevState; - final current = state; + final current = widget.choreographer.assistanceState; _prevState = current; if (!mounted || prev == current) return; @@ -123,7 +122,8 @@ class _StartIGCButtonState extends State return AnimatedBuilder( animation: Listenable.merge([_colorController!, _spinController!]), builder: (context, child) { - final enableFeedback = state.allowsFeedback; + final enableFeedback = + widget.choreographer.assistanceState.allowsFeedback; return Tooltip( message: enableFeedback ? L10n.of(context).check : "", child: Material( @@ -134,10 +134,7 @@ class _StartIGCButtonState extends State child: InkWell( enableFeedback: enableFeedback, customBorder: const CircleBorder(), - onTap: enableFeedback - ? () => - widget.controller.onRequestWritingAssistance(manual: true) - : null, + onTap: enableFeedback ? widget.onPressed : null, onLongPress: enableFeedback ? () => showDialog( context: context, diff --git a/lib/pangea/choreographer/it/it_bar.dart b/lib/pangea/choreographer/it/it_bar.dart index a65f42be9..a4804d6e6 100644 --- a/lib/pangea/choreographer/it/it_bar.dart +++ b/lib/pangea/choreographer/it/it_bar.dart @@ -61,6 +61,17 @@ class ITBarState extends State with SingleTickerProviderStateMixin { super.dispose(); } + FullTextTranslationRequestModel _translationRequest(String text) => + FullTextTranslationRequestModel( + text: text, + tgtLang: + MatrixState.pangeaController.languageController.userL1!.langCode, + userL1: + MatrixState.pangeaController.languageController.userL1!.langCode, + userL2: + MatrixState.pangeaController.languageController.userL2!.langCode, + ); + void _openListener() { if (!mounted) return; @@ -85,45 +96,22 @@ class ITBarState extends State with SingleTickerProviderStateMixin { ValueNotifier get _open => widget.choreographer.itController.open; void _showFeedbackCard( - int index, [ + ContinuanceModel continuance, [ Color? borderColor, - String? choiceFeedback, + bool selected = false, ]) { - final currentStep = widget.choreographer.itController.currentITStep.value; - if (currentStep == null) { - ErrorHandler.logError( - m: "currentITStep is null in showCard", - s: StackTrace.current, - data: { - "index": index, - }, - ); - return; - } - - final text = currentStep.continuances[index].text; - final l1Code = - MatrixState.pangeaController.languageController.userL1!.langCode; - final l2Code = - MatrixState.pangeaController.languageController.userL2!.langCode; - + final text = continuance.text; MatrixState.pAnyState.closeOverlay("it_feedback_card"); OverlayUtil.showPositionedCard( context: context, - cardToShow: choiceFeedback == null + cardToShow: selected ? WordDataCard( word: text, - langCode: l2Code, + langCode: MatrixState + .pangeaController.languageController.userL2!.langCode, fullText: _sourceText.value ?? widget.choreographer.currentText, ) - : ITFeedbackCard( - FullTextTranslationRequestModel( - text: text, - tgtLang: l1Code, - userL1: l1Code, - userL2: l2Code, - ), - ), + : ITFeedbackCard(_translationRequest(text)), maxHeight: 300, maxWidth: 300, borderColor: borderColor, @@ -138,8 +126,7 @@ class ITBarState extends State with SingleTickerProviderStateMixin { MatrixState.pAnyState.closeOverlay("it_feedback_card"); ContinuanceModel continuance; try { - continuance = - widget.choreographer.itController.onSelectContinuance(index); + continuance = widget.choreographer.itController.selectContinuance(index); } catch (e, s) { ErrorHandler.logError( e: e, @@ -157,9 +144,9 @@ class ITBarState extends State with SingleTickerProviderStateMixin { _onCorrectSelection(index); } else { _showFeedbackCard( - index, + continuance, continuance.level == 2 ? ChoreoConstants.yellow : ChoreoConstants.red, - continuance.feedbackText(context), + true, ); } } @@ -169,7 +156,7 @@ class ITBarState extends State with SingleTickerProviderStateMixin { _successTimer = Timer(const Duration(milliseconds: 500), () { if (!mounted) return; try { - widget.choreographer.onAcceptContinuance(index); + widget.choreographer.itController.acceptContinuance(index); } catch (e, s) { ErrorHandler.logError( e: e, @@ -223,13 +210,16 @@ class ITBarState extends State with SingleTickerProviderStateMixin { children: [ _ITBarHeader( onClose: widget.choreographer.itController.closeIT, - setEditing: widget.choreographer.itController.setEditing, + setEditing: + widget.choreographer.itController.setEditingSourceText, editing: widget.choreographer.itController.editing, sourceTextController: _sourceTextController, sourceText: _sourceText, - onSubmitEdits: (_) => widget.choreographer.onSubmitEdits( - _sourceTextController.text, - ), + onSubmitEdits: (_) { + widget.choreographer.itController.submitSourceTextEdits( + _sourceTextController.text, + ); + }, ), Container( padding: const EdgeInsets.symmetric(horizontal: 12.0), @@ -393,7 +383,7 @@ class _ITBarHeader extends StatelessWidget { class _ITChoices extends StatelessWidget { final List continuances; final Function(int) onPressed; - final Function(int) onLongPressed; + final Function(ContinuanceModel) onLongPressed; const _ITChoices({ required this.continuances, @@ -416,7 +406,7 @@ class _ITChoices extends StatelessWidget { ), ], onPressed: (value, index) => onPressed(index), - onLongPress: (value, index) => onLongPressed(index), + onLongPress: (value, index) => onLongPressed(continuances[index]), selectedChoiceIndex: null, langCode: MatrixState.pangeaController.languageController.activeL2Code(), ); diff --git a/lib/pangea/choreographer/it/it_controller.dart b/lib/pangea/choreographer/it/it_controller.dart index 5f9e50d64..af2959df3 100644 --- a/lib/pangea/choreographer/it/it_controller.dart +++ b/lib/pangea/choreographer/it/it_controller.dart @@ -31,6 +31,10 @@ class ITController { ValueNotifier get editing => _editing; ValueNotifier get currentITStep => _currentITStep; ValueNotifier get sourceText => _sourceText; + StreamController acceptedContinuanceStream = + StreamController.broadcast(); + + bool _continuing = false; ITRequestModel _request(String textInput) { assert(_sourceText.value != null); @@ -55,49 +59,48 @@ class ITController { ); } - void clear() { - MatrixState.pAnyState.closeOverlay("it_feedback_card"); - - _open.value = false; - _editing.value = false; - _queue.clear(); - _currentITStep.value = null; - _goldRouteTracker = null; - } - void clearSourceText() { _sourceText.value = null; } void dispose() { + acceptedContinuanceStream.close(); _open.dispose(); - _currentITStep.dispose(); _editing.dispose(); + _currentITStep.dispose(); _sourceText.dispose(); } void openIT(String text) { _sourceText.value = text; _open.value = true; - continueIT(); + _continueIT(); } - void closeIT() => clear(); + void closeIT() { + MatrixState.pAnyState.closeOverlay("it_feedback_card"); - void setEditing(bool value) { + setEditingSourceText(false); + _open.value = false; + _queue.clear(); + _currentITStep.value = null; + _goldRouteTracker = null; + } + + void setEditingSourceText(bool value) { _editing.value = value; } - void onSubmitEdits(String text) { - _editing.value = false; + void submitSourceTextEdits(String text) { _queue.clear(); _currentITStep.value = null; _goldRouteTracker = null; _sourceText.value = text; - continueIT(); + setEditingSourceText(false); + _continueIT(); } - ContinuanceModel onSelectContinuance(int index) { + ContinuanceModel selectContinuance(int index) { if (_currentITStep.value == null) { throw "onSelectContinuance called when _currentITStep is null"; } @@ -116,7 +119,7 @@ class ITController { return _currentITStep.value!.continuances[index]; } - CompletedITStepModel onAcceptContinuance(int chosenIndex) { + void acceptContinuance(int chosenIndex) { if (_currentITStep.value == null) { throw "onAcceptContinuance called when _currentITStep is null"; } @@ -126,17 +129,16 @@ class ITController { throw "onAcceptContinuance called with invalid index $chosenIndex"; } - final completedStep = CompletedITStepModel( - _currentITStep.value!.continuances, - chosen: chosenIndex, + acceptedContinuanceStream.add( + CompletedITStepModel( + _currentITStep.value!.continuances, + chosen: chosenIndex, + ), ); - - continueIT(); - return completedStep; + _continueIT(); } - bool _continuing = false; - Future continueIT() async { + Future _continueIT() async { if (_continuing) return; _continuing = true; diff --git a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart index 31272bf4e..26691b94c 100644 --- a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart +++ b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart @@ -64,9 +64,9 @@ class PangeaTextController extends TextEditingController { return existingStyle?.merge(style) ?? style; } - void setSystemText(String text, EditTypeEnum type) { + void setSystemText(String newText, EditTypeEnum type) { editType = type; - this.text = text; + text = newText; } void _onTextChanged() { @@ -83,7 +83,10 @@ class PangeaTextController extends TextEditingController { void _onUndo(PangeaMatchState match) { try { - choreographer.onUndoReplacement(match); + choreographer.igcController.updateMatch( + match, + PangeaMatchStatusEnum.undo, + ); } catch (e, s) { ErrorHandler.logError( e: e, @@ -111,7 +114,7 @@ class PangeaTextController extends TextEditingController { return _buildPaywallSpan(style); } - if (!choreographer.igcController.hasIGCTextData) { + if (choreographer.igcController.currentText == null) { return TextSpan(text: text, style: style); } @@ -176,13 +179,9 @@ class PangeaTextController extends TextEditingController { List _buildTokenSpan({ TextStyle? defaultStyle, }) { - final openMatches = choreographer.igcController.openMatches ?? const []; - final automaticCorrections = - choreographer.igcController.recentAutomaticCorrections ?? const []; - final textSpanMatches = [ - ...openMatches, - ...automaticCorrections, + ...choreographer.igcController.openMatches, + ...choreographer.igcController.recentAutomaticCorrections, ]..sort( (a, b) => a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset), diff --git a/lib/pangea/learning_settings/widgets/language_mismatch_popup.dart b/lib/pangea/learning_settings/widgets/language_mismatch_popup.dart index 715fd8c3f..0c86a9b50 100644 --- a/lib/pangea/learning_settings/widgets/language_mismatch_popup.dart +++ b/lib/pangea/learning_settings/widgets/language_mismatch_popup.dart @@ -7,8 +7,13 @@ import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class LanguageMismatchPopup extends StatelessWidget { + final String overlayId; final Future Function() onConfirm; - const LanguageMismatchPopup({super.key, required this.onConfirm}); + const LanguageMismatchPopup({ + super.key, + required this.overlayId, + required this.onConfirm, + }); @override Widget build(BuildContext context) { @@ -36,7 +41,7 @@ class LanguageMismatchPopup extends StatelessWidget { context: context, future: onConfirm, ); - MatrixState.pAnyState.closeOverlay(); + MatrixState.pAnyState.closeOverlay(overlayId); }, style: TextButton.styleFrom( backgroundColor: