diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 298a4e35a..e820f9f88 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -40,12 +40,16 @@ import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat/utils/unlocked_morphs_snackbar.dart'; import 'package:fluffychat/pangea/chat/widgets/event_too_large_dialog.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart'; import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/repo/language_mismatch_repo.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/language_mismatch_popup.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/message_analytics_feedback.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -371,7 +375,7 @@ class ChatController extends State if (evt is KeyDownEvent) { // #Pangea // send(); - choreographer.send(context); + onInputBarSubmitted(''); // Pangea# } return KeyEventResult.handled; @@ -776,7 +780,6 @@ class ChatController extends State inputFocus.removeListener(_inputFocusListener); onFocusSub?.cancel(); //#Pangea - choreographer.stateStream.close(); choreographer.dispose(); MatrixState.pAnyState.closeAllOverlays(force: true); showToolbarStream.close(); @@ -1694,7 +1697,7 @@ class ChatController extends State void onSelectMessage(Event event) { // #Pangea - if (choreographer.itController.willOpen) { + if (choreographer.isITOpen) { return; } // Pangea# @@ -1741,10 +1744,22 @@ class ChatController extends State } // #Pangea - void onInputBarSubmitted(String _, BuildContext context) { - // void onInputBarSubmitted(_) { + // void onInputBarSubmitted(String _) { + Future onInputBarSubmitted(String _) async { // send(); - choreographer.send(context); + try { + await choreographer.send(); + } on ShowPaywallException { + PaywallCard.show(context, choreographer.inputTransformTargetKey); + } on OpenMatchesException { + if (choreographer.firstIGCMatch != null) { + OverlayUtil.showIGCMatch( + choreographer.firstIGCMatch!, + choreographer, + context, + ); + } + } // Pangea# FocusScope.of(context).requestFocus(inputFocus); } @@ -2193,11 +2208,14 @@ class ChatController extends State context: context, cardToShow: LanguageMismatchPopup( targetLanguage: targetLanguage, - choreographer: choreographer, onUpdate: () async { - await choreographer.getLanguageHelp(manual: true); - if (choreographer.igc.canShowFirstMatch) { - choreographer.igc.showFirstMatch(context); + final igcMatch = await choreographer.requestLanguageAssistance(); + if (igcMatch != null) { + OverlayUtil.showIGCMatch( + igcMatch, + choreographer, + context, + ); } }, ), diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 1dd9c1dd4..03f6727f7 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -278,11 +278,7 @@ class ChatInputRow extends StatelessWidget { AppConfig.sendOnEnter == true && PlatformInfos.isMobile ? TextInputAction.send : null, - // #Pangea - // onSubmitted: controller.onInputBarSubmitted, - onSubmitted: (_) => - controller.onInputBarSubmitted(_, context), - // Pangea# + onSubmitted: controller.onInputBarSubmitted, onSubmitImage: controller.sendImageFromClipBoard, focusNode: controller.inputFocus, controller: controller.sendController, diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index a0fff1bce..6edd534ac 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -8,7 +8,12 @@ import 'package:matrix/matrix.dart'; import 'package:slugify/slugify.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart'; import 'package:fluffychat/utils/markdown_context_builder.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; @@ -421,6 +426,41 @@ class InputBar extends StatelessWidget { } } + // #Pangea + void onInputTap(BuildContext context, {required FocusNode fNode}) { + fNode.requestFocus(); + + // show the paywall if appropriate + final choreographer = controller!.choreographer; + if (MatrixState + .pangeaController.subscriptionController.subscriptionStatus == + SubscriptionStatus.shouldShowPaywall && + controller!.text.isNotEmpty) { + PaywallCard.show(context, choreographer.inputTransformTargetKey); + return; + } + + // if there is no igc text data, then don't do anything + if (!choreographer.hasIGCTextData) return; + + final selection = controller!.selection; + if (selection.baseOffset >= controller!.text.length) { + return; + } + + final match = choreographer.getMatchByOffset( + selection.baseOffset, + ); + if (match == null) return; + + // if autoplay on and it start then just start it + if (match.updatedMatch.isITStart) { + return choreographer.openIT(match); + } + OverlayUtil.showIGCMatch(match, choreographer, context); + } + // Pangea# + @override Widget build(BuildContext context) { // #Pangea @@ -486,7 +526,7 @@ class InputBar extends StatelessWidget { ? const TextStyle(color: Colors.red) : null, onTap: () { - controller?.onInputTap( + onInputTap( context, fNode: focusNode, ); diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_role_tooltip.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_role_tooltip.dart index e3663e150..14b55ecd8 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_role_tooltip.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_role_tooltip.dart @@ -1,14 +1,13 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; -class ActivityRoleTooltip extends StatefulWidget { +class ActivityRoleTooltip extends StatelessWidget { final Choreographer choreographer; const ActivityRoleTooltip({ @@ -16,48 +15,32 @@ class ActivityRoleTooltip extends StatefulWidget { super.key, }); - @override - State createState() => ActivityRoleTooltipState(); -} - -class ActivityRoleTooltipState extends State { - Room get room => widget.choreographer.chatController.room; - StreamSubscription? _choreoSub; - - @override - void initState() { - super.initState(); - _choreoSub = widget.choreographer.stateStream.stream.listen((event) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); - } + Room get room => choreographer.chatController.room; @override Widget build(BuildContext context) { - if (!room.showActivityChatUI || - room.ownRole?.goal == null || - widget.choreographer.itController.willOpen) { - return const SizedBox(); - } + return ListenableBuilder( + listenable: choreographer, + builder: (context, _) { + if (!room.showActivityChatUI || + room.ownRole?.goal == null || + choreographer.isITOpen) { + return const SizedBox(); + } - return InlineTooltip( - message: room.ownRole!.goal!, - isClosed: room.hasDismissedGoalTooltip, - onClose: () async { - await room.dismissGoalTooltip(); - if (mounted) setState(() {}); + return InlineTooltip( + message: room.ownRole!.goal!, + isClosed: room.hasDismissedGoalTooltip, + onClose: () async { + await room.dismissGoalTooltip(); + }, + padding: const EdgeInsets.only( + left: 16.0, + top: 16.0, + right: 16.0, + ), + ); }, - padding: const EdgeInsets.only( - left: 16.0, - top: 16.0, - right: 16.0, - ), ); } } diff --git a/lib/pangea/chat/widgets/chat_floating_action_button.dart b/lib/pangea/chat/widgets/chat_floating_action_button.dart index cec4648f0..d720535c1 100644 --- a/lib/pangea/chat/widgets/chat_floating_action_button.dart +++ b/lib/pangea/chat/widgets/chat_floating_action_button.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/choreographer/widgets/has_error_button.dart'; import 'package:fluffychat/pangea/choreographer/widgets/language_permissions_warning_buttons.dart'; import 'package:fluffychat/pangea/spaces/models/space_model.dart'; @@ -36,7 +37,6 @@ class ChatFloatingActionButtonState extends State { widget.controller.room, ); showPermissionsError = !itEnabled || !igcEnabled; - debugPrint("showPermissionsError: $showPermissionsError"); if (showPermissionsError) { Future.delayed( @@ -46,12 +46,6 @@ class ChatFloatingActionButtonState extends State { }, ); } - - // Rebuild the widget each time there's an update from choreo (i.e., an error). - _choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) { - setState(() {}); - }); - super.initState(); } @@ -74,19 +68,25 @@ class ChatFloatingActionButtonState extends State { child: const Icon(Icons.arrow_downward_outlined), ); } - if (widget.controller.choreographer.errorService.error != null && - !widget.controller.choreographer.itController.willOpen) { - return ChoreographerHasErrorButton( - widget.controller.choreographer.errorService.error!, - widget.controller.choreographer, - ); - } - return showPermissionsError - ? LanguagePermissionsButtons( - choreographer: widget.controller.choreographer, - roomID: widget.controller.roomId, - ) - : const SizedBox.shrink(); + return ListenableBuilder( + listenable: widget.controller.choreographer, + builder: (context, _) { + if (widget.controller.choreographer.errorService.error != null && + !widget.controller.choreographer.isITOpen) { + return ChoreographerHasErrorButton( + widget.controller.choreographer.errorService.error!, + widget.controller.choreographer, + ); + } + + return showPermissionsError + ? LanguagePermissionsButtons( + choreographer: widget.controller.choreographer, + roomID: widget.controller.roomId, + ) + : const SizedBox.shrink(); + }, + ); } } diff --git a/lib/pangea/chat/widgets/chat_view_background.dart b/lib/pangea/chat/widgets/chat_view_background.dart index ad9def96c..5568f9cb9 100644 --- a/lib/pangea/chat/widgets/chat_view_background.dart +++ b/lib/pangea/chat/widgets/chat_view_background.dart @@ -1,58 +1,41 @@ -import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; -class ChatViewBackground extends StatefulWidget { +class ChatViewBackground extends StatelessWidget { final Choreographer choreographer; const ChatViewBackground(this.choreographer, {super.key}); - @override - ChatViewBackgroundState createState() => ChatViewBackgroundState(); -} - -class ChatViewBackgroundState extends State { - StreamSubscription? _choreoSub; - - @override - void initState() { - // Rebuild the widget each time there's an update from choreo - _choreoSub = widget.choreographer.stateStream.stream.listen((_) { - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return widget.choreographer.itController.willOpen - ? Positioned( - left: 0, - right: 0, - top: 0, - bottom: 0, - child: Material( - borderOnForeground: false, - color: const Color.fromRGBO(0, 0, 0, 1).withAlpha(150), - clipBehavior: Clip.antiAlias, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), - child: Container( - height: double.infinity, - width: double.infinity, - color: Colors.transparent, + return ListenableBuilder( + listenable: choreographer, + builder: (context, _) { + return choreographer.isITOpen + ? Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Material( + borderOnForeground: false, + color: const Color.fromRGBO(0, 0, 0, 1).withAlpha(150), + clipBehavior: Clip.antiAlias, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), + child: Container( + height: double.infinity, + width: double.infinity, + color: Colors.transparent, + ), + ), ), - ), - ), - ) - : const SizedBox.shrink(); + ) + : const SizedBox.shrink(); + }, + ); } } diff --git a/lib/pangea/chat/widgets/pangea_chat_input_row.dart b/lib/pangea/chat/widgets/pangea_chat_input_row.dart index 2419c466d..42f509a7a 100644 --- a/lib/pangea/chat/widgets/pangea_chat_input_row.dart +++ b/lib/pangea/chat/widgets/pangea_chat_input_row.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:animations/animations.dart'; @@ -9,13 +7,15 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/input_bar.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart'; import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -class PangeaChatInputRow extends StatefulWidget { +class PangeaChatInputRow extends StatelessWidget { final ChatController controller; const PangeaChatInputRow({ @@ -23,37 +23,13 @@ class PangeaChatInputRow extends StatefulWidget { super.key, }); - @override - State createState() => PangeaChatInputRowState(); -} - -class PangeaChatInputRowState extends State { - StreamSubscription? _choreoSub; - - @override - void initState() { - // Rebuild the widget each time there's an update from choreo - _choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) { - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); - } - - ChatController get _controller => widget.controller; - LanguageModel? get activel1 => - _controller.pangeaController.languageController.activeL1Model(); + controller.pangeaController.languageController.activeL1Model(); LanguageModel? get activel2 => - _controller.pangeaController.languageController.activeL2Model(); + controller.pangeaController.languageController.activeL2Model(); - String hintText() { - if (_controller.choreographer.itController.willOpen) { + String hintText(BuildContext context) { + if (controller.choreographer.isITOpen) { return L10n.of(context).buildTranslation; } return activel1 != null && @@ -72,202 +48,207 @@ class PangeaChatInputRowState extends State { final theme = Theme.of(context); const height = 48.0; - if (widget.controller.selectMode) { + if (controller.selectMode) { return const SizedBox(height: height); } - return Column( - children: [ - CompositedTransformTarget( - link: _controller.choreographer.inputLayerLinkAndKey.link, - child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all( - Radius.circular(8.0), + return ListenableBuilder( + listenable: controller.choreographer, + builder: (context, _) { + return Column( + children: [ + CompositedTransformTarget( + link: controller.choreographer.inputLayerLinkAndKey.link, + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(8.0), + ), + ), + child: Row( + key: controller.choreographer.inputLayerLinkAndKey.key, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 4), + AnimatedContainer( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + height: height, + width: + controller.sendController.text.isEmpty ? height : 0, + alignment: Alignment.center, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: PopupMenuButton( + useRootNavigator: true, + icon: const Icon(Icons.add_outlined), + onSelected: controller.onAddPopupMenuButtonSelected, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'file', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + child: Icon(Icons.attachment_outlined), + ), + title: Text(L10n.of(context).sendFile), + contentPadding: const EdgeInsets.all(0), + ), + ), + PopupMenuItem( + value: 'image', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + child: Icon(Icons.image_outlined), + ), + title: Text(L10n.of(context).sendImage), + contentPadding: const EdgeInsets.all(0), + ), + ), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'camera', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + child: Icon(Icons.camera_alt_outlined), + ), + title: Text(L10n.of(context).openCamera), + contentPadding: const EdgeInsets.all(0), + ), + ), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'camera-video', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + child: Icon(Icons.videocam_outlined), + ), + title: Text(L10n.of(context).openVideoCamera), + contentPadding: const EdgeInsets.all(0), + ), + ), + if (PlatformInfos.isMobile) + PopupMenuItem( + value: 'location', + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.brown, + foregroundColor: Colors.white, + child: Icon(Icons.gps_fixed_outlined), + ), + title: Text(L10n.of(context).shareLocation), + contentPadding: const EdgeInsets.all(0), + ), + ), + ], + ), + ), + if (FluffyThemes.isColumnMode(context)) + Container( + height: height, + width: height, + alignment: Alignment.center, + child: IconButton( + tooltip: L10n.of(context).emojis, + icon: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation 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, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 0.0), + child: InputBar( + room: controller.room, + minLines: 1, + maxLines: 8, + autofocus: !PlatformInfos.isMobile, + keyboardType: TextInputType.multiline, + textInputAction: AppConfig.sendOnEnter == true && + PlatformInfos.isMobile + ? TextInputAction.send + : null, + onSubmitted: controller.onInputBarSubmitted, + onSubmitImage: controller.sendImageFromClipBoard, + focusNode: controller.inputFocus, + controller: controller.sendController, + decoration: const InputDecoration( + contentPadding: EdgeInsets.only( + left: 6.0, + right: 6.0, + bottom: 6.0, + top: 3.0, + ), + disabledBorder: InputBorder.none, + hintMaxLines: 1, + border: InputBorder.none, + enabledBorder: InputBorder.none, + filled: false, + ), + onChanged: controller.onInputBarChanged, + hintText: hintText(context), + ), + ), + ), + StartIGCButton( + controller: controller, + ), + Container( + height: height, + width: height, + alignment: Alignment.center, + child: PlatformInfos.platformCanRecord && + controller.sendController.text.isEmpty && + !controller.choreographer.isITOpen + ? 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), + ) + : ChoreographerSendButton(controller: controller), + ), + ], + ), ), ), - child: Row( - key: _controller.choreographer.inputLayerLinkAndKey.key, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 4), - AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - height: height, - width: _controller.sendController.text.isEmpty ? height : 0, - alignment: Alignment.center, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: PopupMenuButton( - useRootNavigator: true, - icon: const Icon(Icons.add_outlined), - onSelected: _controller.onAddPopupMenuButtonSelected, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'file', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - child: Icon(Icons.attachment_outlined), - ), - title: Text(L10n.of(context).sendFile), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: 'image', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - child: Icon(Icons.image_outlined), - ), - title: Text(L10n.of(context).sendImage), - contentPadding: const EdgeInsets.all(0), - ), - ), - if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'camera', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - child: Icon(Icons.camera_alt_outlined), - ), - title: Text(L10n.of(context).openCamera), - contentPadding: const EdgeInsets.all(0), - ), - ), - if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'camera-video', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - child: Icon(Icons.videocam_outlined), - ), - title: Text(L10n.of(context).openVideoCamera), - contentPadding: const EdgeInsets.all(0), - ), - ), - if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'location', - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.brown, - foregroundColor: Colors.white, - child: Icon(Icons.gps_fixed_outlined), - ), - title: Text(L10n.of(context).shareLocation), - contentPadding: const EdgeInsets.all(0), - ), - ), - ], - ), - ), - if (FluffyThemes.isColumnMode(context)) - Container( - height: height, - width: height, - alignment: Alignment.center, - child: IconButton( - tooltip: L10n.of(context).emojis, - icon: PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation 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, - ), - ), - 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: (String value) => - _controller.onInputBarSubmitted(value, context), - onSubmitImage: _controller.sendImageFromClipBoard, - focusNode: _controller.inputFocus, - controller: _controller.sendController, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only( - left: 6.0, - right: 6.0, - bottom: 6.0, - top: 3.0, - ), - disabledBorder: InputBorder.none, - hintMaxLines: 1, - border: InputBorder.none, - enabledBorder: InputBorder.none, - filled: false, - ), - onChanged: _controller.onInputBarChanged, - hintText: hintText(), - ), - ), - ), - StartIGCButton( - controller: _controller, - ), - Container( - height: height, - width: height, - alignment: Alignment.center, - child: PlatformInfos.platformCanRecord && - _controller.sendController.text.isEmpty && - !_controller.choreographer.itController.willOpen - ? 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), - ) - : ChoreographerSendButton(controller: _controller), - ), - ], - ), - ), - ), - ], + ], + ); + }, ); } } diff --git a/lib/pangea/choreographer/constants/choreo_constants.dart b/lib/pangea/choreographer/constants/choreo_constants.dart index 7be97ce31..c48827d87 100644 --- a/lib/pangea/choreographer/constants/choreo_constants.dart +++ b/lib/pangea/choreographer/constants/choreo_constants.dart @@ -8,4 +8,6 @@ class ChoreoConstants { static const green = Colors.green; static const yellow = Color.fromARGB(255, 206, 152, 2); static const red = Colors.red; + static const int msBeforeIGCStart = 10000; + static const int maxLength = 1000; } diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 8a9abe734..44a2a3564 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -1,99 +1,180 @@ import 'dart:async'; -import 'dart:developer'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart'; import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart'; import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart'; +import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart'; import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; -import 'package:fluffychat/pangea/choreographer/utils/input_paste_listener.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/common/utils/any_state_holder.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'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; -import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; -import 'package:fluffychat/pangea/spaces/models/space_model.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import '../../../widgets/matrix.dart'; import 'error_service.dart'; import 'it_controller.dart'; -enum ChoreoMode { igc, it } +class OpenMatchesException implements Exception {} -class Choreographer { - PangeaController pangeaController; - ChatController chatController; - late PangeaTextController _textController; +class ShowPaywallException implements Exception {} + +class Choreographer extends ChangeNotifier { + final PangeaController pangeaController; + final ChatController chatController; + + late PangeaTextController textController; late ITController itController; late IgcController igc; late ErrorService errorService; - bool isFetching = false; + ChoreoRecord? _choreoRecord; + + bool _isFetching = false; int _timesClicked = 0; - final int msBeforeIGCStart = 10000; - - Timer? debounceTimer; - ChoreoRecord? choreoRecord; - // last checked by IGC or translation + Timer? _debounceTimer; String? _lastChecked; - ChoreoMode choreoMode = ChoreoMode.igc; + ChoreoMode _choreoMode = ChoreoMode.igc; + String? _sourceText; - final StreamController stateStream = StreamController.broadcast(); StreamSubscription? _languageStream; StreamSubscription? _settingsUpdateStream; - late AssistanceState _currentAssistanceState; - - String? translatedText; Choreographer(this.pangeaController, this.chatController) { _initialize(); } - _initialize() { - _textController = PangeaTextController(choreographer: this); - InputPasteListener(_textController, onPaste); + + int get timesClicked => _timesClicked; + bool get isFetching => _isFetching; + ChoreoMode get choreoMode => _choreoMode; + + String? get sourceText => _sourceText; + String get currentText => textController.text; + + void _initialize() { + textController = PangeaTextController(choreographer: this); + itController = ITController(this); igc = IgcController(this); - errorService = ErrorService(this); - _textController.addListener(_onChangeListener); + + errorService = ErrorService(); + errorService.addListener(notifyListeners); + + textController.addListener(_onChange); + _languageStream = pangeaController.userController.languageStream.stream.listen((update) { clear(); - setState(); + notifyListeners(); }); _settingsUpdateStream = pangeaController.userController.settingsUpdateStream.stream.listen((_) { - setState(); + notifyListeners(); }); - _currentAssistanceState = assistanceState; clear(); } - void send(BuildContext context) { - debugPrint("can send message: $canSendMessage"); + void clear() { + _choreoMode = ChoreoMode.igc; + _lastChecked = null; + _timesClicked = 0; + _isFetching = false; + _choreoRecord = null; + _sourceText = null; + itController.clear(); + igc.clear(); + _resetDebounceTimer(); + } + @override + void dispose() { + super.dispose(); + errorService.dispose(); + textController.dispose(); + _languageStream?.cancel(); + _settingsUpdateStream?.cancel(); + TtsController.stop(); + } + + void onPaste(value) { + _initChoreoRecord(); + _choreoRecord!.pastedStrings.add(value); + } + + void onClickSend() { + if (assistanceState == AssistanceState.fetched) { + _timesClicked++; + + // if user is doing IT, call closeIT here to + // ensure source text is replaced when needed + if (isITOpen && _timesClicked > 1) { + closeIT(); + } + } + } + + void setChoreoMode(ChoreoMode mode) { + _choreoMode = mode; + notifyListeners(); + } + + void _resetDebounceTimer() { + if (_debounceTimer != null) { + _debounceTimer?.cancel(); + _debounceTimer = null; + } + } + + void _initChoreoRecord() { + _choreoRecord ??= ChoreoRecord( + originalText: textController.text, + choreoSteps: [], + openMatches: [], + ); + } + + void _startLoading() { + _lastChecked = textController.text; + _isFetching = true; + notifyListeners(); + } + + void _stopLoading() { + _isFetching = false; + notifyListeners(); + } + + Future requestLanguageAssistance() async { + await _getLanguageAssistance(manual: true); + if (igc.canShowFirstMatch) { + return igc.onShowFirstMatch(); + } + return null; + } + + Future send() async { // if isFetching, already called to getLanguageHelp and hasn't completed yet // could happen if user clicked send button multiple times in a row - if (isFetching) return; + if (_isFetching) return; if (igc.canShowFirstMatch) { - igc.showFirstMatch(context); - return; + throw OpenMatchesException(); } else if (isRunningIT) { // If the user is in the middle of IT, don't send the message. // If they've already clicked the send button once, this will @@ -101,16 +182,14 @@ class Choreographer { return; } - final isSubscribed = pangeaController.subscriptionController.isSubscribed; - if (isSubscribed != null && !isSubscribed) { - // don't want to run IGC if user isn't subscribed, so either - // show the paywall if applicable or just send the message - final status = pangeaController.subscriptionController.subscriptionStatus; - status == SubscriptionStatus.shouldShowPaywall - ? PaywallCard.show(context, chatController) - : chatController.send( - message: chatController.sendController.text, - ); + final subscriptionStatus = + pangeaController.subscriptionController.subscriptionStatus; + + if (subscriptionStatus != SubscriptionStatus.subscribed) { + if (subscriptionStatus == SubscriptionStatus.shouldShowPaywall) { + throw ShowPaywallException(); + } + chatController.send(message: chatController.sendController.text); return; } @@ -119,23 +198,79 @@ class Choreographer { return; } - if (!igc.hasRelevantIGCTextData && !itController.dismissed) { - getLanguageHelp().then((value) => _sendWithIGC(context)); + if (!igc.hasIGCTextData && !itController.dismissed) { + await _getLanguageAssistance(); + await send(); } else { - _sendWithIGC(context); + _sendWithIGC(); } } - Future _sendWithIGC(BuildContext context) async { - if (!canSendMessage) { - // It's possible that the reason user can't send message is because they're in the middle of IT. If this is the case, - // do nothing (there's no matches to show). The user can click the send button again to override this. - if (!isRunningIT) { - igc.showFirstMatch(context); - } + /// Handles any changes to the text input + void _onChange() { + if (_lastChecked != null && _lastChecked == textController.text) { return; } + _lastChecked = textController.text; + + if (textController.editType == EditType.igc || + textController.editType == EditType.itDismissed) { + textController.editType = EditType.keyboard; + return; + } + + // Close any open IGC/IT overlays + MatrixState.pAnyState.closeOverlay(); + if (errorService.isError) return; + + igc.clear(); + _resetDebounceTimer(); + + if (textController.editType == EditType.it) { + _getLanguageAssistance(); + } else { + _sourceText = null; + _debounceTimer ??= Timer( + const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart), + () => _getLanguageAssistance(), + ); + } + + //Note: we don't set the keyboard type on each keyboard stroke so this is how we default to + //a change being from the keyboard unless explicitly set to one of the other + //types when that action happens (e.g. an it/igc choice is selected) + textController.editType = EditType.keyboard; + } + + /// Fetches the language help for the current text, including grammar correction, language detection, + /// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or + /// or if autoIGC is not enabled and the user has not manually requested it. + /// [onlyTokensAndLanguageDetection] will + Future _getLanguageAssistance({ + bool manual = false, + }) async { + if (errorService.isError) return; + final SubscriptionStatus canSendStatus = + pangeaController.subscriptionController.subscriptionStatus; + + if (canSendStatus != SubscriptionStatus.subscribed || + l2Lang == null || + l1Lang == null || + (!igcEnabled && !itEnabled) || + (!isAutoIGCEnabled && !manual && _choreoMode != ChoreoMode.it)) { + return; + } + + _resetDebounceTimer(); + _initChoreoRecord(); + + _startLoading(); + await (isRunningIT ? itController.continueIT() : igc.getIGCTextData()); + _stopLoading(); + } + + Future _sendWithIGC() async { if (chatController.sendController.text.trim().isEmpty) { return; } @@ -143,10 +278,10 @@ class Choreographer { final message = chatController.sendController.text; final fakeEventId = chatController.sendFakeMessage(); final PangeaRepresentation? originalWritten = - choreoRecord?.includedIT == true && translatedText != null + _choreoRecord?.includedIT == true && _sourceText != null ? PangeaRepresentation( langCode: l1LangCode ?? LanguageKeys.unknownLanguage, - text: translatedText!, + text: _sourceText!, originalWritten: true, originalSent: false, ) @@ -192,7 +327,7 @@ class Choreographer { "currentText": message, "l1LangCode": l1LangCode, "l2LangCode": l2LangCode, - "choreoRecord": choreoRecord?.toJson(), + "choreoRecord": _choreoRecord?.toJson(), }, level: e is TimeoutException ? SentryLevel.warning : SentryLevel.error, ); @@ -202,522 +337,174 @@ class Choreographer { originalSent: originalSent, originalWritten: originalWritten, tokensSent: tokensSent, - choreo: choreoRecord, + choreo: _choreoRecord, tempEventId: fakeEventId, ); clear(); } } - _resetDebounceTimer() { - if (debounceTimer != null) { - debounceTimer?.cancel(); - debounceTimer = null; - } - } - - void _initChoreoRecord() { - choreoRecord ??= ChoreoRecord( - originalText: textController.text, - choreoSteps: [], - openMatches: [], - ); - } - - void onITStart(PangeaMatchState itMatch) { + void openIT(PangeaMatchState itMatch) { if (!itMatch.updatedMatch.isITStart) { - throw Exception("this isn't an itStart match!"); + throw Exception("Attempted to open IT with a non-IT start match"); } - choreoMode = ChoreoMode.it; - itController.initializeIT( - ITStartData(_textController.text, null), - ); - translatedText = _textController.text; + _choreoMode = ChoreoMode.it; + _sourceText = textController.text; + itController.openIT(); + igc.clear(); - _textController.setSystemText("", EditType.itStart); + textController.setSystemText("", EditType.it); _initChoreoRecord(); itMatch.setStatus(PangeaMatchStatus.accepted); - choreoRecord!.addRecord( - _textController.text, + _choreoRecord!.addRecord( + textController.text, match: itMatch.updatedMatch, ); + notifyListeners(); } - /// Handles any changes to the text input - _onChangeListener() { - // Rebuild the IGC button if the state has changed. - // This accounts for user typing after initial IGC has completed - if (_currentAssistanceState != assistanceState) { - setState(); - } - - if (_noChange) { - return; - } - - _lastChecked = _textController.text; - - if (_textController.editType == EditType.igc || - _textController.editType == EditType.itDismissed) { - _textController.editType = EditType.keyboard; - return; - } - - // not sure if this is necessary now - MatrixState.pAnyState.closeOverlay(); - - if (errorService.isError) { - return; - } - - igc.clear(); - - _resetDebounceTimer(); - - // we store translated text in the choreographer to save at the original written - // text, but if the user edits the text after the translation, reset it, since the - // sent text may not be an exact translation of the original text - if (_textController.editType == EditType.keyboard) { - translatedText = null; - } - - if (editTypeIsKeyboard) { - debounceTimer ??= Timer( - Duration(milliseconds: msBeforeIGCStart), - () => getLanguageHelp(), - ); - } else { - getLanguageHelp(); - } - - //Note: we don't set the keyboard type on each keyboard stroke so this is how we default to - //a change being from the keyboard unless explicitly set to one of the other - //types when that action happens (e.g. an it/igc choice is selected) - textController.editType = EditType.keyboard; + void closeIT() { + itController.closeIT(); + errorService.resetError(); + notifyListeners(); } - /// Fetches the language help for the current text, including grammar correction, language detection, - /// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or - /// or if autoIGC is not enabled and the user has not manually requested it. - /// [onlyTokensAndLanguageDetection] will - Future getLanguageHelp({ - bool manual = false, - }) async { - try { - if (errorService.isError) return; - final SubscriptionStatus canSendStatus = - pangeaController.subscriptionController.subscriptionStatus; - - if (canSendStatus != SubscriptionStatus.subscribed || - l2Lang == null || - l1Lang == null || - (!igcEnabled && !itEnabled) || - (!isAutoIGCEnabled && !manual && choreoMode != ChoreoMode.it)) { - return; - } - - _resetDebounceTimer(); - startLoading(); - _initChoreoRecord(); - - // if getting language assistance after finishing IT, - // reset the itController - if (choreoMode == ChoreoMode.it && itController.isTranslationDone) { - itController.clear(); - } - - await (isRunningIT - ? itController.getTranslationData(_useCustomInput) - : igc.getIGCTextData()); - } catch (err, stack) { - ErrorHandler.logError( - e: err, - s: stack, - data: { - "l2Lang": l2Lang?.toJson(), - "l1Lang": l1Lang?.toJson(), - "choreoMode": choreoMode, - "igcEnabled": igcEnabled, - "itEnabled": itEnabled, - "isAutoIGCEnabled": isAutoIGCEnabled, - "isTranslationDone": itController.isTranslationDone, - "useCustomInput": _useCustomInput, - }, - ); - } finally { - stopLoading(); - } + Continuance onSelectContinuance(int index) { + final continuance = itController.onSelectContinuance(index); + notifyListeners(); + return continuance; } - void onITChoiceSelect(ITStep step) { - _textController.setSystemText( - _textController.text + step.continuances[step.chosen!].text, - step.continuances[step.chosen!].gold - ? EditType.itGold - : EditType.itStandard, + void onAcceptContinuance(int index) { + final step = itController.getAcceptedITStep(index); + textController.setSystemText( + textController.text + step.continuances[step.chosen].text, + EditType.it, + ); + textController.selection = TextSelection.collapsed( + offset: textController.text.length, ); - _textController.selection = - TextSelection.collapsed(offset: _textController.text.length); _initChoreoRecord(); - choreoRecord!.addRecord(_textController.text, step: step); - - giveInputFocus(); + _choreoRecord!.addRecord(textController.text, step: step); + chatController.inputFocus.requestFocus(); + notifyListeners(); } - Future onAcceptReplacement({ + void setSourceText(String? text) { + _sourceText = text; + } + + void setEditingSourceText(bool value) { + itController.setEditing(value); + notifyListeners(); + } + + void submitSourceTextEdits(String text) { + _sourceText = text; + itController.onSubmitEdits(); + notifyListeners(); + } + + PangeaMatchState? getMatchByOffset(int offset) => + igc.getMatchByOffset(offset); + + void clearMatches(Object error) { + MatrixState.pAnyState.closeAllOverlays(); + igc.clearMatches(); + errorService.setError(ChoreoError(raw: error)); + } + + Future fetchSpanDetails({ required PangeaMatchState match, - }) async { - try { - if (igc.igcTextData == null) { - ErrorHandler.logError( - e: "onReplacementSelect with null igcTextData", - s: StackTrace.current, - data: { - "match": match.toJson(), - }, - ); - MatrixState.pAnyState.closeOverlay(); - return; - } - if (match.updatedMatch.match.selectedChoice == null) { - ErrorHandler.logError( - e: "onReplacementSelect with null selectedChoice", - s: StackTrace.current, - data: { - "igctextData": igc.igcTextData?.toJson(), - "match": match.toJson(), - }, - ); - MatrixState.pAnyState.closeOverlay(); - return; - } - - final isNormalizationError = - match.updatedMatch.match.isNormalizationError(); - - final updatedMatch = igc.igcTextData!.acceptReplacement( - match, - PangeaMatchStatus.accepted, + bool force = false, + }) => + igc.fetchSpanDetails( + match: match, + force: force, ); - _textController.setSystemText( - igc.igcTextData!.currentText, - EditType.igc, - ); + void onAcceptReplacement({ + required PangeaMatchState match, + }) { + final updatedMatch = igc.acceptReplacement( + match, + PangeaMatchStatus.accepted, + ); - //if it's the right choice, replace in text - if (!isNormalizationError) { - _initChoreoRecord(); - choreoRecord!.addRecord( - _textController.text, - match: updatedMatch, - ); - } + textController.setSystemText( + igc.currentText!, + EditType.igc, + ); - MatrixState.pAnyState.closeOverlay(); - setState(); - } catch (err, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: err, - s: stack, - data: { - "igctextData": igc.igcTextData?.toJson(), - "match": match.toJson(), - }, + if (!updatedMatch.match.isNormalizationError()) { + _initChoreoRecord(); + _choreoRecord!.addRecord( + textController.text, + match: updatedMatch, ); - igc.clear(); - } finally { - setState(); } + + MatrixState.pAnyState.closeOverlay(); + notifyListeners(); } void onUndoReplacement(PangeaMatchState match) { - try { - igc.igcTextData?.undoReplacement(match); - choreoRecord?.choreoSteps.removeWhere( - (step) => step.acceptedOrIgnoredMatch == match.updatedMatch, - ); + igc.undoReplacement(match); + _choreoRecord?.choreoSteps.removeWhere( + (step) => step.acceptedOrIgnoredMatch == match.updatedMatch, + ); - _textController.setSystemText( - igc.igcTextData!.currentText, - EditType.igc, + textController.setSystemText( + igc.currentText!, + EditType.igc, + ); + MatrixState.pAnyState.closeOverlay(); + notifyListeners(); + } + + void onIgnoreMatch({required PangeaMatchState match}) { + final updatedMatch = igc.ignoreReplacement(match); + if (!updatedMatch.match.isNormalizationError()) { + _initChoreoRecord(); + _choreoRecord!.addRecord( + textController.text, + match: updatedMatch, ); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "igctextData": igc.igcTextData?.toJson(), - "match": match.toJson(), - }, - ); - } finally { - MatrixState.pAnyState.closeOverlay(); - setState(); } + MatrixState.pAnyState.closeOverlay(); + notifyListeners(); } void acceptNormalizationMatches() { - final normalizationsMatches = igc.igcTextData!.openNormalizationMatches; - if (normalizationsMatches.isEmpty) return; + final normalizationsMatches = igc.openNormalizationMatches; + if (normalizationsMatches?.isEmpty ?? true) return; _initChoreoRecord(); - for (final match in normalizationsMatches) { + for (final match in normalizationsMatches!) { match.selectChoice( match.updatedMatch.match.choices!.indexWhere( (c) => c.isBestCorrection, ), ); - final updatedMatch = igc.igcTextData!.acceptReplacement( + final updatedMatch = igc.acceptReplacement( match, PangeaMatchStatus.automatic, ); - _textController.setSystemText( - igc.igcTextData!.currentText, + textController.setSystemText( + igc.currentText!, EditType.igc, ); - choreoRecord!.addRecord( + _choreoRecord!.addRecord( currentText, match: updatedMatch, ); } - } - - void onIgnoreMatch({required PangeaMatchState match}) { - try { - if (igc.igcTextData == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not be in onIgnoreMatch with null igcTextData", - s: StackTrace.current, - data: {}, - ); - return; - } - - final updatedMatch = igc.igcTextData!.ignoreReplacement(match); - igc.onIgnoreMatch(updatedMatch); - - if (!updatedMatch.match.isNormalizationError()) { - _initChoreoRecord(); - choreoRecord!.addRecord( - _textController.text, - match: updatedMatch, - ); - } - } catch (err, stack) { - debugger(when: kDebugMode); - Sentry.addBreadcrumb( - Breadcrumb( - data: { - "igcTextData": igc.igcTextData?.toJson(), - "match": match.toJson(), - }, - ), - ); - ErrorHandler.logError( - e: err, - s: stack, - data: { - "igctextData": igc.igcTextData?.toJson(), - }, - ); - igc.clear(); - } finally { - setState(); - } - } - - void giveInputFocus() { - Future.delayed(Duration.zero, () { - chatController.inputFocus.requestFocus(); - }); - } - - String get currentText => _textController.text; - - PangeaTextController get textController => _textController; - - String get accessToken => pangeaController.userController.accessToken; - - clear() { - choreoMode = ChoreoMode.igc; - _lastChecked = null; - _timesClicked = 0; - isFetching = false; - choreoRecord = null; - translatedText = null; - itController.clear(); - igc.clear(); - _resetDebounceTimer(); - } - - Future onPaste(value) async { - _initChoreoRecord(); - choreoRecord!.pastedStrings.add(value); - } - - dispose() { - _textController.dispose(); - _languageStream?.cancel(); - _settingsUpdateStream?.cancel(); - stateStream.close(); - TtsController.stop(); - } - - LanguageModel? get l2Lang { - return pangeaController.languageController.activeL2Model(); - } - - String? get l2LangCode => l2Lang?.langCode; - - LanguageModel? get l1Lang => - pangeaController.languageController.activeL1Model(); - - String? get l1LangCode => l1Lang?.langCode; - - String? get userId => pangeaController.userController.userId; - - bool get _noChange => - _lastChecked != null && _lastChecked == _textController.text; - - bool get isRunningIT => - choreoMode == ChoreoMode.it && !itController.isTranslationDone; - - void startLoading() { - _lastChecked = _textController.text; - isFetching = true; - setState(); - } - - void stopLoading() { - isFetching = false; - setState(); - } - - void incrementTimesClicked() { - if (assistanceState == AssistanceState.fetched) { - _timesClicked++; - - // if user is doing IT, call closeIT here to - // ensure source text is replaced when needed - if (itController.isOpen && _timesClicked > 1) { - itController.closeIT(); - } - } - } - - get roomId => chatController.roomId; - - bool get _useCustomInput => [ - EditType.keyboard, - EditType.igc, - EditType.alternativeTranslation, - ].contains(_textController.editType); - - bool get editTypeIsKeyboard => EditType.keyboard == _textController.editType; - - setState() { - if (!stateStream.isClosed) { - stateStream.add(0); - } - _currentAssistanceState = assistanceState; - } - - LayerLinkAndKey get itBarLinkAndKey => - MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey); - - String get itBarTransformTargetKey => 'it_bar$roomId'; - - LayerLinkAndKey get inputLayerLinkAndKey => - MatrixState.pAnyState.layerLinkAndKey(inputTransformTargetKey); - - String get inputTransformTargetKey => 'input$roomId'; - - LayerLinkAndKey get itBotLayerLinkAndKey => - MatrixState.pAnyState.layerLinkAndKey(itBotTransformTargetKey); - - String get itBotTransformTargetKey => 'itBot$roomId'; - - bool get igcEnabled => pangeaController.permissionsController.isToolEnabled( - ToolSetting.interactiveGrammar, - chatController.room, - ); - - bool get itEnabled => pangeaController.permissionsController.isToolEnabled( - ToolSetting.interactiveTranslator, - chatController.room, - ); - - bool get isAutoIGCEnabled => - pangeaController.permissionsController.isToolEnabled( - ToolSetting.autoIGC, - chatController.room, - ); - - AssistanceState get assistanceState { - final isSubscribed = pangeaController.subscriptionController.isSubscribed; - if (isSubscribed != null && !isSubscribed) { - return AssistanceState.noSub; - } - - if (currentText.isEmpty && itController.sourceText == null) { - return AssistanceState.noMessage; - } - - if ((igc.igcTextData?.hasOpenMatches ?? false) || isRunningIT) { - return AssistanceState.fetched; - } - - if (isFetching) { - return AssistanceState.fetching; - } - - if (igc.igcTextData == null) { - return AssistanceState.notFetched; - } - - return AssistanceState.complete; - } - - bool get canSendMessage { - // if there's an error, let them send. we don't want to block them from sending in this case - if (errorService.isError || - l2Lang == null || - l1Lang == null || - _timesClicked > 1) { - return true; - } - - // if they're in IT mode, don't let them send - if (itEnabled && isRunningIT) return false; - - // if they've turned off IGC then let them send the message when they want - if (!isAutoIGCEnabled) return true; - - // if we're in the middle of fetching results, don't let them send - if (isFetching) return false; - - // they're supposed to run IGC but haven't yet, don't let them send - if (igc.igcTextData == null) { - return itController.dismissed; - } - - // if they have relevant matches, don't let them send - final hasITMatches = igc.igcTextData!.hasOpenITMatches; - final hasIGCMatches = igc.igcTextData!.hasOpenIGCMatches; - if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) { - return false; - } - - // otherwise, let them send - return true; + notifyListeners(); } } diff --git a/lib/pangea/choreographer/controllers/error_service.dart b/lib/pangea/choreographer/controllers/error_service.dart index 542cade9f..13755809c 100644 --- a/lib/pangea/choreographer/controllers/error_service.dart +++ b/lib/pangea/choreographer/controllers/error_service.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import '../../common/utils/error_handler.dart'; class ChoreoError { @@ -16,17 +15,14 @@ class ChoreoError { IconData get icon => Icons.error_outline; } -class ErrorService { +class ErrorService extends ChangeNotifier { ChoreoError? _error; int coolDownSeconds = 0; - final Choreographer controller; - ErrorService(this.controller); + ErrorService(); bool get isError => _error != null; - ChoreoError? get error => _error; - Duration get defaultCooldown { coolDownSeconds += 3; return Duration(seconds: coolDownSeconds); @@ -34,7 +30,7 @@ class ErrorService { final List _errorCache = []; - setError(ChoreoError? error, {Duration? duration}) { + void setError(ChoreoError? error) { if (_errorCache.contains(error?.raw.toString())) { return; } @@ -44,25 +40,21 @@ class ErrorService { } _error = error; - Future.delayed(duration ?? defaultCooldown, () { + Future.delayed(defaultCooldown, () { clear(); - _setState(); + notifyListeners(); }); - _setState(); + notifyListeners(); } - setErrorAndLock(ChoreoError? error) { + void setErrorAndLock(ChoreoError? error) { _error = error; - _setState(); + notifyListeners(); } - resetError() { + void resetError() { clear(); - _setState(); - } - - void _setState() { - controller.setState(); + notifyListeners(); } void clear() { diff --git a/lib/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart b/lib/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart new file mode 100644 index 000000000..cf0a53387 --- /dev/null +++ b/lib/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart @@ -0,0 +1,26 @@ +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/spaces/models/space_model.dart'; + +extension ChoregrapherUserSettingsExtension on Choreographer { + LanguageModel? get l2Lang => + pangeaController.languageController.activeL2Model(); + String? get l2LangCode => l2Lang?.langCode; + LanguageModel? get l1Lang => + pangeaController.languageController.activeL1Model(); + String? get l1LangCode => l1Lang?.langCode; + + bool get igcEnabled => pangeaController.permissionsController.isToolEnabled( + ToolSetting.interactiveGrammar, + chatController.room, + ); + bool get itEnabled => pangeaController.permissionsController.isToolEnabled( + ToolSetting.interactiveTranslator, + chatController.room, + ); + bool get isAutoIGCEnabled => + pangeaController.permissionsController.isToolEnabled( + ToolSetting.autoIGC, + chatController.room, + ); +} diff --git a/lib/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart b/lib/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart new file mode 100644 index 000000000..35a1a5886 --- /dev/null +++ b/lib/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart @@ -0,0 +1,72 @@ +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; +import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart'; +import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart'; +import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; +import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; + +extension ChoregrapherUserSettingsExtension on Choreographer { + bool get isITOpen => itController.open; + bool get isEditingSourceText => itController.editing; + bool get isITDone => itController.isTranslationDone; + bool get isRunningIT => choreoMode == ChoreoMode.it && !isITDone; + List? get itStepContinuances => itController.continuances; + + String? get currentIGCText => igc.currentText; + PangeaMatchState? get openIGCMatch => igc.openMatch; + PangeaMatchState? get firstIGCMatch => igc.firstOpenMatch; + List? get openIGCMatches => igc.openMatches; + List? get closedIGCMatches => igc.closedMatches; + bool get canShowFirstIGCMatch => igc.canShowFirstMatch; + bool get hasIGCTextData => igc.hasIGCTextData; + + AssistanceState get assistanceState { + final isSubscribed = pangeaController.subscriptionController.isSubscribed; + if (isSubscribed == false) return AssistanceState.noSub; + if (currentText.isEmpty && sourceText == null) { + return AssistanceState.noMessage; + } + + if (igc.hasOpenMatches || isRunningIT) { + return AssistanceState.fetched; + } + + if (isFetching) return AssistanceState.fetching; + if (!igc.hasIGCTextData) return AssistanceState.notFetched; + return AssistanceState.complete; + } + + bool get canSendMessage { + // if there's an error, let them send. we don't want to block them from sending in this case + if (errorService.isError || + l2Lang == null || + l1Lang == null || + timesClicked > 1) { + return true; + } + + // if they're in IT mode, don't let them send + if (itEnabled && isRunningIT) return false; + + // if they've turned off IGC then let them send the message when they want + if (!isAutoIGCEnabled) return true; + + // if we're in the middle of fetching results, don't let them send + if (isFetching) return false; + + // they're supposed to run IGC but haven't yet, don't let them send + if (!igc.hasIGCTextData) { + return itController.dismissed; + } + + // if they have relevant matches, don't let them send + final hasITMatches = igc.hasOpenITMatches; + final hasIGCMatches = igc.hasOpenIGCMatches; + if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) { + return false; + } + + // otherwise, let them send + return true; + } +} diff --git a/lib/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart b/lib/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart new file mode 100644 index 000000000..c6c21156d --- /dev/null +++ b/lib/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart @@ -0,0 +1,12 @@ +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +extension ChoregrapherUserSettingsExtension on Choreographer { + LayerLinkAndKey get itBarLinkAndKey => + MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey); + String get itBarTransformTargetKey => 'it_bar${chatController.roomId}'; + LayerLinkAndKey get inputLayerLinkAndKey => + MatrixState.pAnyState.layerLinkAndKey(inputTransformTargetKey); + String get inputTransformTargetKey => 'input${chatController.roomId}'; +} diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index 84077f73b..1dffb4a1b 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -1,14 +1,15 @@ import 'dart:async'; -import 'dart:developer'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:async/async.dart'; import 'package:matrix/matrix.dart' hide Result; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; +import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart'; +import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; import 'package:fluffychat/pangea/choreographer/models/igc_text_data_model.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; @@ -16,37 +17,103 @@ import 'package:fluffychat/pangea/choreographer/repo/igc_repo.dart'; import 'package:fluffychat/pangea/choreographer/repo/igc_request_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/span_data_repo.dart'; import 'package:fluffychat/pangea/choreographer/repo/span_data_request.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../common/utils/error_handler.dart'; -import '../../common/utils/overlay.dart'; class IgcController { - Choreographer choreographer; - IGCTextData? igcTextData; + final Choreographer _choreographer; + IGCTextData? _igcTextData; - IgcController(this.choreographer); + IgcController(this._choreographer); + + String? get currentText => _igcTextData?.currentText; + bool get hasOpenMatches => _igcTextData?.hasOpenMatches == true; + bool get hasOpenITMatches => _igcTextData?.hasOpenITMatches == true; + bool get hasOpenIGCMatches => _igcTextData?.hasOpenIGCMatches == true; + + PangeaMatchState? get openMatch => _igcTextData?.openMatch; + PangeaMatchState? get firstOpenMatch => _igcTextData?.firstOpenMatch; + List? get openMatches => _igcTextData?.openMatches; + List? get closedMatches => _igcTextData?.closedMatches; + List? get openNormalizationMatches => + _igcTextData?.openNormalizationMatches; + + bool get canShowFirstMatch => _igcTextData?.firstOpenMatch != null; + bool get hasIGCTextData { + if (_igcTextData == null) return false; + return _igcTextData!.currentText == _choreographer.currentText; + } + + void clear() { + _igcTextData = null; + MatrixState.pAnyState.closeAllOverlays(); + } + + void clearMatches() => _igcTextData?.clearMatches(); + + PangeaMatchState? onShowFirstMatch() { + if (!canShowFirstMatch) { + throw "should not be calling showFirstMatch with this igcTextData."; + } + + final match = _igcTextData!.firstOpenMatch!; + if (match.updatedMatch.isITStart && _igcTextData != null) { + _choreographer.openIT(match); + return null; + } + + _choreographer.chatController.inputFocus.unfocus(); + return match; + } + + PangeaMatchState? getMatchByOffset(int offset) => + _igcTextData?.getMatchByOffset(offset); + + PangeaMatch acceptReplacement( + PangeaMatchState match, + PangeaMatchStatus status, + ) { + if (_igcTextData == null) { + throw "acceptReplacement called with null igcTextData"; + } + return _igcTextData!.acceptReplacement(match, status); + } + + PangeaMatch ignoreReplacement(PangeaMatchState match) { + IgcRepo.ignore(match.updatedMatch); + if (_igcTextData == null) { + throw "should not be in onIgnoreMatch with null igcTextData"; + } + return _igcTextData!.ignoreReplacement(match); + } + + void undoReplacement(PangeaMatchState match) { + if (_igcTextData == null) { + throw "undoReplacement called with null igcTextData"; + } + _igcTextData!.undoReplacement(match); + } Future getIGCTextData() async { - if (choreographer.currentText.isEmpty) return clear(); - debugPrint('getIGCTextData called with ${choreographer.currentText}'); + if (_choreographer.currentText.isEmpty) return clear(); + debugPrint('getIGCTextData called with ${_choreographer.currentText}'); final IGCRequestModel reqBody = IGCRequestModel( - fullText: choreographer.currentText, - userId: choreographer.pangeaController.userController.userId!, - userL1: choreographer.l1LangCode!, - userL2: choreographer.l2LangCode!, - enableIGC: - choreographer.igcEnabled && choreographer.choreoMode != ChoreoMode.it, - enableIT: - choreographer.itEnabled && choreographer.choreoMode != ChoreoMode.it, + fullText: _choreographer.currentText, + userId: _choreographer.pangeaController.userController.userId!, + userL1: _choreographer.l1LangCode!, + userL2: _choreographer.l2LangCode!, + enableIGC: _choreographer.igcEnabled && + _choreographer.choreoMode != ChoreoMode.it, + enableIT: _choreographer.itEnabled && + _choreographer.choreoMode != ChoreoMode.it, prevMessages: _prevMessages(), ); final res = await IgcRepo.get( - choreographer.accessToken, + _choreographer.pangeaController.userController.accessToken, reqBody, ).timeout( (const Duration(seconds: 10)), @@ -58,78 +125,80 @@ class IgcController { ); if (res.isError) { - choreographer.errorService.setError(ChoreoError(raw: res.error)); - clear(); - return; - } - - // this will happen when the user changes the input while igc is fetching results - if (res.result!.originalInput.trim() != choreographer.currentText.trim()) { - return; - } - - final response = res.result!; - igcTextData = IGCTextData( - originalInput: response.originalInput, - matches: response.matches, - ); - choreographer.acceptNormalizationMatches(); - - for (final match in igcTextData!.openMatches) { - setSpanDetails(match: match); - } - } - - void onIgnoreMatch(PangeaMatch match) { - IgcRepo.ignore(match); - } - - bool get canShowFirstMatch { - return igcTextData?.firstOpenMatch != null; - } - - void showFirstMatch(BuildContext context) { - if (!canShowFirstMatch) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not be calling showFirstMatch with this igcTextData.", - s: StackTrace.current, - data: { - "igcTextData": igcTextData?.toJson(), - }, + _igcTextData = IGCTextData( + originalInput: reqBody.fullText, + matches: [], ); return; } - final match = igcTextData!.firstOpenMatch!; - if (match.updatedMatch.isITStart && igcTextData != null) { - choreographer.onITStart(match); + // this will happen when the user changes the input while igc is fetching results + if (res.result!.originalInput.trim() != _choreographer.currentText.trim()) { return; } - choreographer.chatController.inputFocus.unfocus(); - MatrixState.pAnyState.closeAllOverlays(); - OverlayUtil.showPositionedCard( - overlayKey: - "span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}", - context: context, - cardToShow: SpanCard( - match: match, - choreographer: choreographer, - ), - maxHeight: 325, - maxWidth: 325, - transformTargetId: choreographer.inputTransformTargetKey, - onDismiss: () => choreographer.setState(), - ignorePointer: true, - isScrollable: false, + final response = res.result!; + _igcTextData = IGCTextData( + originalInput: response.originalInput, + matches: response.matches, ); + + try { + _choreographer.acceptNormalizationMatches(); + if (_igcTextData != null) { + for (final match in _igcTextData!.openMatches) { + fetchSpanDetails(match: match); + } + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "igcResponse": response.toJson(), + }, + ); + } + } + + Future fetchSpanDetails({ + required PangeaMatchState match, + bool force = false, + }) async { + final span = match.updatedMatch.match; + if (span.isNormalizationError() && !force) { + return; + } + + final response = await SpanDataRepo.get( + _choreographer.pangeaController.userController.accessToken, + request: SpanDetailsRequest( + userL1: _choreographer.l1LangCode!, + userL2: _choreographer.l2LangCode!, + enableIGC: _choreographer.igcEnabled, + enableIT: _choreographer.itEnabled, + span: span, + ), + ).timeout( + (const Duration(seconds: 10)), + onTimeout: () { + return Result.error( + TimeoutException('Span details request timed out'), + ); + }, + ); + + if (response.isError) { + _choreographer.clearMatches(response.error!); + return; + } + + _igcTextData?.setSpanData(match, response.result!.span); } - /// Get the content of previous text and audio messages in chat. - /// Passed to IGC request to add context. List _prevMessages({int numMessages = 5}) { - final List events = choreographer.chatController.visibleEvents + final List events = _choreographer.chatController.visibleEvents .where( (e) => e.type == EventTypes.Message && @@ -144,9 +213,9 @@ class IgcController { ? event.content.toString() : PangeaMessageEvent( event: event, - timeline: choreographer.chatController.timeline!, + timeline: _choreographer.chatController.timeline!, ownMessage: event.senderId == - choreographer.pangeaController.matrixState.client.userID, + _choreographer.pangeaController.matrixState.client.userID, ).getSpeechToTextLocal()?.transcript.text.trim(); // trim whitespace if (content == null) continue; messages.add( @@ -162,51 +231,4 @@ class IgcController { } return messages; } - - bool get hasRelevantIGCTextData { - if (igcTextData == null) return false; - - if (igcTextData!.currentText != choreographer.currentText) { - debugPrint( - "returning isIGCTextDataRelevant false because text has changed", - ); - return false; - } - return true; - } - - clear() { - igcTextData = null; - MatrixState.pAnyState.closeAllOverlays(); - } - - Future setSpanDetails({ - required PangeaMatchState match, - bool force = false, - }) async { - final span = match.updatedMatch.match; - if (span.isNormalizationError() && !force) { - return; - } - - final response = await SpanDataRepo.get( - choreographer.accessToken, - request: SpanDetailsRequest( - userL1: choreographer.l1LangCode!, - userL2: choreographer.l2LangCode!, - enableIGC: choreographer.igcEnabled, - enableIT: choreographer.itEnabled, - span: span, - ), - ); - - if (response.isError) { - choreographer.errorService.setError(ChoreoError(raw: response.error)); - clear(); - return; - } - - igcTextData?.setSpanData(match, response.result!.span); - choreographer.setState(); - } } diff --git a/lib/pangea/choreographer/controllers/it_controller.dart b/lib/pangea/choreographer/controllers/it_controller.dart index 68e878709..df88f4704 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -1,17 +1,14 @@ import 'dart:async'; -import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:async/async.dart'; -import 'package:http/http.dart' as http; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; +import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.dart'; import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; import 'package:fluffychat/pangea/choreographer/repo/it_repo.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../models/it_step.dart'; @@ -20,121 +17,180 @@ import '../repo/it_response_model.dart'; import 'choreographer.dart'; class ITController { - Choreographer choreographer; + final Choreographer _choreographer; - bool _isOpen = false; - bool _willOpen = false; - bool _isEditingSourceText = false; - bool dismissed = false; + ITStep? _currentITStep; + final List> _queue = []; + GoldRouteTracker? _goldRouteTracker; - ITStartData? _itStartData; - String? sourceText; - List completedITSteps = []; - CurrentITStep? currentITStep; - Completer? nextITStep; - GoldRouteTracker goldRouteTracker = GoldRouteTracker.defaultTracker; - List payLoadIds = []; + bool _open = false; + bool _editing = false; + bool _dismissed = false; - ITController(this.choreographer); + ITController(this._choreographer); - void clear() { - _isOpen = false; - _willOpen = false; - MatrixState.pAnyState.closeOverlay("it_feedback_card"); + bool get open => _open; + bool get editing => _editing; + bool get dismissed => _dismissed; + List? get continuances => _currentITStep?.continuances; + bool get isTranslationDone => _currentITStep?.isFinal ?? false; - _isEditingSourceText = false; - dismissed = false; + String? get _sourceText => _choreographer.sourceText; - _itStartData = null; - sourceText = null; - completedITSteps = []; - currentITStep = null; - nextITStep = null; - goldRouteTracker = GoldRouteTracker.defaultTracker; - payLoadIds = []; - - choreographer.choreoMode = ChoreoMode.igc; - choreographer.setState(); + ITRequestModel _request(String textInput) { + assert(_sourceText != null); + return ITRequestModel( + text: _sourceText!, + customInput: textInput, + sourceLangCode: + MatrixState.pangeaController.languageController.activeL1Code()!, + targetLangCode: + MatrixState.pangeaController.languageController.activeL2Code()!, + userId: _choreographer.chatController.room.client.userID!, + roomId: _choreographer.chatController.room.id, + goldTranslation: _goldRouteTracker?.fullTranslation, + goldContinuances: _goldRouteTracker?.continuances, + ); } - Duration get animationSpeed => const Duration(milliseconds: 300); - - Future initializeIT(ITStartData itStartData) async { - _willOpen = true; - Future.delayed(const Duration(microseconds: 100), () { - _isOpen = true; - }); - _itStartData = itStartData; - } + void openIT() => _open = true; void closeIT() { // if the user hasn't gone through any IT steps, reset the text - if (completedITSteps.isEmpty && sourceText != null) { - choreographer.textController.setSystemText( - sourceText!, + if (_choreographer.currentText.isEmpty && _sourceText != null) { + _choreographer.textController.setSystemText( + _sourceText!, EditType.itDismissed, ); } - clear(); - choreographer.errorService.resetError(); - dismissed = true; + + clear(dismissed: true); } - /// if IGC isn't positive that text is full L1 then translate to L1 - Future _setSourceText() async { - if (_itStartData == null || _itStartData!.text.isEmpty) { - Sentry.addBreadcrumb( - Breadcrumb( - message: "choreo context", - data: { - "igcTextData": choreographer.igc.igcTextData?.toJson(), - "currentText": choreographer.currentText, - }, - ), - ); - throw Exception("null _itStartData or empty text in _setSourceText"); - } - debugPrint("_setSourceText with detectedLang ${_itStartData!.langCode}"); - // if (_itStartData!.langCode == choreographer.l1LangCode) { - sourceText = _itStartData!.text; - choreographer.translatedText = sourceText; - return; - // } + void clear({bool dismissed = false}) { + MatrixState.pAnyState.closeOverlay("it_feedback_card"); - // final FullTextTranslationResponseModel res = - // await FullTextTranslationRepo.translate( - // accessToken: await choreographer.accessToken, - // request: FullTextTranslationRequestModel( - // text: _itStartData!.text, - // tgtLang: choreographer.l1LangCode!, - // srcLang: _itStartData!.langCode, - // userL1: choreographer.l1LangCode!, - // userL2: choreographer.l2LangCode!, - // ), - // ); - // sourceText = res.bestTranslation; + _open = false; + _editing = false; + _dismissed = dismissed; + _queue.clear(); + _currentITStep = null; + _goldRouteTracker = null; + + _choreographer.setChoreoMode(ChoreoMode.igc); + _choreographer.setSourceText(null); } - // used 1) at very beginning (with custom input = null) - // and 2) if they make direct edits to the text field - Future getTranslationData(bool useCustomInput) async { - final String currentText = choreographer.currentText; + void setEditing(bool value) => _editing = value; - if (sourceText == null) await _setSourceText(); + void onSubmitEdits() { + _editing = false; + _queue.clear(); + _currentITStep = null; + _goldRouteTracker = null; + continueIT(); + } - if (useCustomInput && currentITStep != null) { - completedITSteps.add( - ITStep( - currentITStep!.continuances, - customInput: currentText, - ), - ); + Continuance onSelectContinuance(int index) { + if (_currentITStep == null) { + throw "onSelectContinuance called with null currentITStep"; } - currentITStep = null; + if (index < 0 || index >= _currentITStep!.continuances.length) { + throw "onSelectContinuance called with invalid index $index"; + } - // During first IT step, next step will not be set - if (nextITStep == null) { + final step = _currentITStep!.continuances[index]; + _currentITStep!.continuances[index] = step.copyWith( + wasClicked: true, + ); + return _currentITStep!.continuances[index]; + } + + CompletedITStep getAcceptedITStep(int chosenIndex) { + if (_currentITStep == null) { + throw "getAcceptedITStep called with null currentITStep"; + } + + if (chosenIndex < 0 || chosenIndex >= _currentITStep!.continuances.length) { + throw "getAcceptedITStep called with invalid index $chosenIndex"; + } + + return CompletedITStep( + _currentITStep!.continuances, + chosen: chosenIndex, + ); + } + + Future continueIT() async { + if (_currentITStep == null) { + await _initTranslationData(); + return; + } + + if (_queue.isEmpty) { + _choreographer.closeIT(); + return; + } + + final nextStepCompleter = _queue.removeAt(0); + try { + _currentITStep = await nextStepCompleter.future; + } catch (e) { + if (_open) { + _choreographer.errorService.setErrorAndLock( + ChoreoError(raw: e), + ); + } + } + } + + Future _initTranslationData() async { + final String currentText = _choreographer.currentText; + final res = await ITRepo.get(_request(currentText)).timeout( + const Duration(seconds: 10), + onTimeout: () { + return Result.error( + TimeoutException("ITRepo.get timed out after 10 seconds"), + ); + }, + ); + + if (_sourceText == null || !_open) return; + if (res.isError || res.result?.goldContinuances == null) { + _choreographer.errorService.setErrorAndLock( + ChoreoError(raw: res.asError), + ); + return; + } + + final result = res.result!; + _goldRouteTracker = GoldRouteTracker( + result.goldContinuances!, + _sourceText!, + ); + + _currentITStep = ITStep( + sourceText: _sourceText!, + currentText: currentText, + responseModel: res.result!, + storedGoldContinuances: _goldRouteTracker!.continuances, + ); + + _fillITStepQueue(); + } + + Future _fillITStepQueue() async { + if (_sourceText == null || _goldRouteTracker!.continuances.length < 2) { + return; + } + + final sourceText = _sourceText!; + String currentText = + _choreographer.currentText + _goldRouteTracker!.continuances[0].text; + + for (int i = 1; i < _goldRouteTracker!.continuances.length; i++) { + _queue.add(Completer()); final res = await ITRepo.get(_request(currentText)).timeout( const Duration(seconds: 10), onTimeout: () { @@ -145,192 +201,29 @@ class ITController { ); if (res.isError) { - if (_willOpen) { - choreographer.errorService.setErrorAndLock( - ChoreoError(raw: res.asError), - ); - } - return; - } - - if (sourceText == null) { - return; - } - - final result = res.result!; - if (result.goldContinuances != null && - result.goldContinuances!.isNotEmpty) { - goldRouteTracker = GoldRouteTracker( - result.goldContinuances!, - sourceText!, + _queue.last.completeError(res.asError!); + break; + } else { + final step = ITStep( + sourceText: sourceText, + currentText: currentText, + responseModel: res.result!, + storedGoldContinuances: _goldRouteTracker!.continuances, ); + _queue.last.complete(step); } - currentITStep = CurrentITStep( - sourceText: sourceText!, - currentText: currentText, - responseModel: result, - storedGoldContinuances: goldRouteTracker.continuances, - ); - - _addPayloadId(result); - } else { - currentITStep = await nextITStep!.future; - } - - if (isTranslationDone) { - nextITStep = null; - closeIT(); - } else { - nextITStep = Completer(); - final nextStep = await _getNextTranslationData(); - nextITStep?.complete(nextStep); + currentText += _goldRouteTracker!.continuances[i].text; } } - - Future _getNextTranslationData() async { - if (sourceText == null) { - ErrorHandler.logError( - e: Exception("sourceText is null in getNextTranslationData"), - data: { - "sourceText": sourceText, - "currentITStepPayloadID": currentITStep?.payloadId, - "continuances": goldRouteTracker.continuances.map((e) => e.toJson()), - }, - ); - return null; - } - - if (completedITSteps.length >= goldRouteTracker.continuances.length) { - return null; - } - - final String currentText = choreographer.currentText; - final String nextText = - goldRouteTracker.continuances[completedITSteps.length].text; - - final res = await ITRepo.get( - _request(currentText + nextText), - ); - - if (sourceText == null) return null; - if (res.isError) { - choreographer.errorService.setErrorAndLock( - ChoreoError(raw: res.asError), - ); - return null; - } - - return CurrentITStep( - sourceText: sourceText!, - currentText: nextText, - responseModel: res.result!, - storedGoldContinuances: goldRouteTracker.continuances, - ); - } - - Future onEditSourceTextSubmit(String newSourceText) async { - try { - _isOpen = true; - _isEditingSourceText = false; - _itStartData = ITStartData(newSourceText, choreographer.l1LangCode); - completedITSteps = []; - currentITStep = null; - nextITStep = null; - goldRouteTracker = GoldRouteTracker.defaultTracker; - payLoadIds = []; - - _setSourceText(); - getTranslationData(false); - } catch (err, stack) { - debugger(when: kDebugMode); - if (err is! http.Response) { - ErrorHandler.logError( - e: err, - s: stack, - data: { - "newSourceText": newSourceText, - "l1Lang": choreographer.l1LangCode, - }, - ); - } - choreographer.errorService.setErrorAndLock( - ChoreoError(raw: err), - ); - } finally { - choreographer.textController.setSystemText( - "", - EditType.other, - ); - } - } - - ITRequestModel _request(String textInput) => ITRequestModel( - text: sourceText!, - customInput: textInput, - sourceLangCode: sourceLangCode, - targetLangCode: targetLangCode, - userId: choreographer.userId!, - roomId: choreographer.roomId!, - goldTranslation: goldRouteTracker.fullTranslation, - goldContinuances: goldRouteTracker.continuances, - ); - - //maybe we store IT data in the same format? make a specific kind of match? - void selectTranslation(int chosenIndex) { - if (currentITStep == null) return; - final itStep = ITStep( - currentITStep!.continuances, - chosen: chosenIndex, - ); - - completedITSteps.add(itStep); - choreographer.onITChoiceSelect(itStep); - choreographer.setState(); - } - - String get uniqueKeyForLayerLink => "itChoices${choreographer.roomId}"; - - void _addPayloadId(ITResponseModel res) { - payLoadIds.add(res.payloadId); - } - - bool get isTranslationDone => currentITStep != null && currentITStep!.isFinal; - - bool get isOpen => _isOpen; - - bool get willOpen => _willOpen; - - String get targetLangCode => choreographer.l2LangCode!; - - String get sourceLangCode => choreographer.l1LangCode!; - - bool get isLoading => choreographer.isFetching; - - void setIsEditingSourceText(bool value) { - _isEditingSourceText = value; - choreographer.setState(); - } - - bool get isEditingSourceText => _isEditingSourceText; -} - -class ITStartData { - String text; - String? langCode; - - ITStartData(this.text, this.langCode); } class GoldRouteTracker { - late String _originalText; - List continuances; + final String _originalText; + final List continuances; - GoldRouteTracker(this.continuances, String originalText) { - _originalText = originalText; - } - - static get defaultTracker => GoldRouteTracker([], ""); + const GoldRouteTracker(this.continuances, String originalText) + : _originalText = originalText; Continuance? currentContinuance({ required String currentText, @@ -362,13 +255,11 @@ class GoldRouteTracker { } } -class CurrentITStep { +class ITStep { late List continuances; late bool isFinal; - late String? translationId; - late int payloadId; - CurrentITStep({ + ITStep({ required String sourceText, required String currentText, required ITResponseModel responseModel, @@ -379,8 +270,6 @@ class CurrentITStep { final goldTracker = GoldRouteTracker(gold, sourceText); isFinal = responseModel.isFinal; - translationId = responseModel.translationId; - payloadId = responseModel.payloadId; if (responseModel.continuances.isEmpty) { continuances = []; @@ -410,8 +299,4 @@ class CurrentITStep { } } } - - // get continuance with highest level - Continuance get best => - continuances.reduce((a, b) => a.level < b.level ? a : b); } diff --git a/lib/pangea/choreographer/controllers/pangea_text_controller.dart b/lib/pangea/choreographer/controllers/pangea_text_controller.dart index 5e3ba7cfa..57ec593c1 100644 --- a/lib/pangea/choreographer/controllers/pangea_text_controller.dart +++ b/lib/pangea/choreographer/controllers/pangea_text_controller.dart @@ -1,122 +1,96 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; +import 'package:fluffychat/pangea/choreographer/constants/match_rule_ids.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; +import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; -import 'package:fluffychat/pangea/choreographer/utils/match_style_util.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/autocorrect_span.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import '../../common/utils/overlay.dart'; import '../enums/edit_type.dart'; import 'choreographer.dart'; class PangeaTextController extends TextEditingController { - Choreographer choreographer; - + final Choreographer choreographer; EditType editType = EditType.keyboard; + String _currentText = ''; + PangeaTextController({ - String? text, required this.choreographer, }) { - text ??= ''; - this.text = text; + addListener(() { + final difference = + text.characters.length - _currentText.characters.length; + + if (difference > 1 && editType == EditType.keyboard) { + choreographer.onPaste( + text.characters + .getRange( + _currentText.characters.length, + text.characters.length, + ) + .join(), + ); + } + _currentText = text; + }); } - static const int maxLength = 1000; - bool get exceededMaxLength => text.length >= maxLength; + bool get exceededMaxLength => text.length >= ChoreoConstants.maxLength; - bool forceKeepOpen = false; + TextStyle _underlineStyle(Color color) => TextStyle( + decoration: TextDecoration.underline, + decorationColor: color, + decorationThickness: 5, + ); + + Color _underlineColor(PangeaMatch match) { + if (match.status == PangeaMatchStatus.automatic) { + return const Color.fromARGB(187, 132, 96, 224); + } + + switch (match.match.rule?.id ?? "unknown") { + case MatchRuleIds.interactiveTranslation: + return const Color.fromARGB(187, 132, 96, 224); + case MatchRuleIds.tokenNeedsTranslation: + case MatchRuleIds.tokenSpanNeedsTranslation: + return const Color.fromARGB(186, 255, 132, 0); + default: + return const Color.fromARGB(149, 255, 17, 0); + } + } + + TextStyle _textStyle( + PangeaMatch match, + TextStyle? existingStyle, + bool isOpenMatch, + ) { + double opacityFactor = 1.0; + if (!isOpenMatch) { + opacityFactor = 0.2; + } + + final alpha = (255 * opacityFactor).round(); + final style = _underlineStyle(_underlineColor(match).withAlpha(alpha)); + return existingStyle?.merge(style) ?? style; + } void setSystemText(String text, EditType type) { editType = type; this.text = text; } - void onInputTap(BuildContext context, {required FocusNode fNode}) { - fNode.requestFocus(); - forceKeepOpen = true; - if (!context.mounted) { - debugger(when: kDebugMode); - return; - } - - // show the paywall if appropriate - if (choreographer - .pangeaController.subscriptionController.subscriptionStatus == - SubscriptionStatus.shouldShowPaywall && - !choreographer.isFetching && - text.isNotEmpty) { - PaywallCard.show(context, choreographer.chatController); - return; - } - - // if there is no igc text data, then don't do anything - if (choreographer.igc.igcTextData == null) return; - - // debugPrint( - // "onInputTap matches are ${choreographer.igc.igcTextData?.matches.map((e) => e.match.rule.id).toList().toString()}"); - - // if user is just trying to get their cursor into the text input field to add soemthing, - // then don't interrupt them - if (selection.baseOffset >= text.length) { - return; - } - - final match = choreographer.igc.igcTextData!.getMatchByOffset( - selection.baseOffset, - ); - if (match == null) return; - - // if autoplay on and it start then just start it - if (match.updatedMatch.isITStart) { - return choreographer.onITStart(match); - } - - MatrixState.pAnyState.closeAllOverlays(); - OverlayUtil.showPositionedCard( - overlayKey: - "span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}", - context: context, - maxHeight: 400, - maxWidth: 350, - cardToShow: SpanCard( - match: match, - choreographer: choreographer, - ), - transformTargetId: choreographer.inputTransformTargetKey, - onDismiss: () => choreographer.setState(), - ignorePointer: true, - isScrollable: false, - ); - } - @override TextSpan buildTextSpan({ required BuildContext context, TextStyle? style, required bool withComposing, }) { - // If the composing range is out of range for the current text, ignore it to - // preserve the tree integrity, otherwise in release mode a RangeError will - // be thrown and this EditableText will be built with a broken subtree. - // debugPrint("composing? $withComposing"); - // if (!value.isComposingRangeValid || !withComposing) { - // debugPrint("just returning straight text"); - // // debugger(when: kDebugMode); - // return TextSpan(style: style, text: text); - // } - // if (value.isComposingRangeValid) { - // debugPrint("composing before ${value.composing.textBefore(value.text)}"); - // debugPrint("composing inside ${value.composing.textInside(value.text)}"); - // debugPrint("composing after ${value.composing.textAfter(value.text)}"); - // } - final SubscriptionStatus canSendStatus = choreographer .pangeaController.subscriptionController.subscriptionStatus; if (canSendStatus == SubscriptionStatus.shouldShowPaywall && @@ -125,33 +99,38 @@ class PangeaTextController extends TextEditingController { return TextSpan( text: text, style: style?.merge( - MatchStyleUtil.underlineStyle( + _underlineStyle( const Color.fromARGB(187, 132, 96, 224), ), ), ); - } else if (choreographer.igc.igcTextData == null || text.isEmpty) { + } else if (!choreographer.hasIGCTextData || text.isEmpty) { return TextSpan(text: text, style: style); } else { - final parts = text.split(choreographer.igc.igcTextData!.currentText); - + final parts = text.split(choreographer.currentIGCText!); if (parts.length == 1 || parts.length > 2) { return TextSpan(text: text, style: style); } - List inlineSpans = []; - try { - inlineSpans = constructTokenSpan( - defaultStyle: style, - onUndo: choreographer.onUndoReplacement, - ); - } catch (e) { - choreographer.errorService.setError( - ChoreoError(raw: e), - ); - inlineSpans = [TextSpan(text: text, style: style)]; - choreographer.igc.clear(); - } + final inlineSpans = constructTokenSpan( + defaultStyle: style, + onUndo: (match) { + try { + choreographer.onUndoReplacement(match); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "match": match.toJson(), + }, + ); + MatrixState.pAnyState.closeOverlay(); + choreographer.clearMatches(e); + } + }, + ); return TextSpan( style: style, @@ -169,7 +148,7 @@ class PangeaTextController extends TextEditingController { VoidCallback onUndo, ) { if (match.updatedMatch.status == PangeaMatchStatus.automatic) { - final span = choreographer.igc.igcTextData!.currentText.characters + final span = choreographer.currentIGCText!.characters .getRange( match.updatedMatch.match.offset, match.updatedMatch.match.offset + match.updatedMatch.match.length, @@ -193,7 +172,7 @@ class PangeaTextController extends TextEditingController { ); } else { return TextSpan( - text: choreographer.igc.igcTextData!.currentText.characters + text: choreographer.currentIGCText!.characters .getRange( match.updatedMatch.match.offset, match.updatedMatch.match.offset + match.updatedMatch.match.length, @@ -210,20 +189,20 @@ class PangeaTextController extends TextEditingController { required void Function(PangeaMatchState) onUndo, TextStyle? defaultStyle, }) { - final automaticMatches = choreographer.igc.igcTextData!.closedMatches - .where((m) => m.updatedMatch.status == PangeaMatchStatus.automatic) - .toList(); + final automaticMatches = choreographer.closedIGCMatches + ?.where((m) => m.updatedMatch.status == PangeaMatchStatus.automatic) + .toList() ?? + []; final textSpanMatches = [ - ...choreographer.igc.igcTextData!.openMatches, + ...choreographer.openIGCMatches ?? [], ...automaticMatches, ]..sort( (a, b) => a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset), ); - final currentText = choreographer.igc.igcTextData!.currentText; - + final currentText = choreographer.currentIGCText!; final spans = []; int cursor = 0; @@ -235,9 +214,8 @@ class PangeaTextController extends TextEditingController { spans.add(TextSpan(text: text, style: defaultStyle)); } - final openMatch = - choreographer.igc.igcTextData?.openMatch?.updatedMatch.match; - final style = MatchStyleUtil.textStyle( + final openMatch = choreographer.openIGCMatch?.updatedMatch.match; + final style = _textStyle( match.updatedMatch, defaultStyle, openMatch != null && diff --git a/lib/pangea/choreographer/enums/choreo_mode.dart b/lib/pangea/choreographer/enums/choreo_mode.dart new file mode 100644 index 000000000..1ffa0d9b7 --- /dev/null +++ b/lib/pangea/choreographer/enums/choreo_mode.dart @@ -0,0 +1 @@ +enum ChoreoMode { igc, it } diff --git a/lib/pangea/choreographer/enums/edit_type.dart b/lib/pangea/choreographer/enums/edit_type.dart index cf6621a53..3c9f99428 100644 --- a/lib/pangea/choreographer/enums/edit_type.dart +++ b/lib/pangea/choreographer/enums/edit_type.dart @@ -1,10 +1,7 @@ enum EditType { - itStandard, igc, - keyboard, - alternativeTranslation, - itGold, - itStart, + it, itDismissed, + keyboard, other, } diff --git a/lib/pangea/choreographer/models/choreo_record.dart b/lib/pangea/choreographer/models/choreo_record.dart index ffb91555d..22f352d99 100644 --- a/lib/pangea/choreographer/models/choreo_record.dart +++ b/lib/pangea/choreographer/models/choreo_record.dart @@ -190,7 +190,7 @@ class ChoreoRecord { return text; } - void addRecord(String text, {PangeaMatch? match, ITStep? step}) { + void addRecord(String text, {PangeaMatch? match, CompletedITStep? step}) { if (match != null && step != null) { throw Exception("match and step should not both be defined"); } @@ -243,7 +243,7 @@ class ChoreoRecordStep { /// last step in list may contain open final PangeaMatch? acceptedOrIgnoredMatch; - final ITStep? itStep; + final CompletedITStep? itStep; ChoreoRecordStep({ this.edits, @@ -264,7 +264,9 @@ class ChoreoRecordStep { acceptedOrIgnoredMatch: json[_acceptedOrIgnoredMatchKey] != null ? PangeaMatch.fromJson(json[_acceptedOrIgnoredMatchKey]) : null, - itStep: json[_stepKey] != null ? ITStep.fromJson(json[_stepKey]) : null, + itStep: json[_stepKey] != null + ? CompletedITStep.fromJson(json[_stepKey]) + : null, ); } diff --git a/lib/pangea/choreographer/models/igc_text_data_model.dart b/lib/pangea/choreographer/models/igc_text_data_model.dart index f59c31e86..acca08551 100644 --- a/lib/pangea/choreographer/models/igc_text_data_model.dart +++ b/lib/pangea/choreographer/models/igc_text_data_model.dart @@ -46,6 +46,8 @@ class IGCTextData { List get openNormalizationMatches => _state.openNormalizationMatches; + void clearMatches() => _state.clearMatches(); + void setSpanData(PangeaMatchState match, SpanData spanData) { _state.setSpanData(match, spanData); } diff --git a/lib/pangea/choreographer/models/igc_text_state.dart b/lib/pangea/choreographer/models/igc_text_state.dart index b649e90e3..14566ad89 100644 --- a/lib/pangea/choreographer/models/igc_text_state.dart +++ b/lib/pangea/choreographer/models/igc_text_state.dart @@ -88,6 +88,11 @@ class IGCTextState { ); } + void clearMatches() { + _openMatches.clear(); + _closedMatches.clear(); + } + void _filterIgnoredMatches() { for (final match in _openMatches) { if (IgcRepo.isIgnored(match.updatedMatch)) { @@ -110,12 +115,13 @@ class IGCTextState { PangeaMatchState match, PangeaMatchStatus status, ) { - final openMatch = _openMatches.firstWhereOrNull( + final openMatch = _openMatches.firstWhere( (m) => m.originalMatch == match.originalMatch, + orElse: () => throw "No open match found for acceptReplacement", ); if (match.updatedMatch.match.selectedChoice == null) { - throw "match.match.selectedChoice is null in acceptReplacement"; + throw "acceptReplacement called with null selectedChoice"; } match.setStatus(status); @@ -132,8 +138,9 @@ class IGCTextState { } PangeaMatch ignoreReplacement(PangeaMatchState match) { - final openMatch = _openMatches.firstWhereOrNull( + final openMatch = _openMatches.firstWhere( (m) => m.originalMatch == match.originalMatch, + orElse: () => throw "No open match found for ignoreReplacement", ); match.setStatus(PangeaMatchStatus.ignored); @@ -143,8 +150,9 @@ class IGCTextState { } void undoReplacement(PangeaMatchState match) { - final closedMatch = _closedMatches.firstWhereOrNull( + final closedMatch = _closedMatches.firstWhere( (m) => m.originalMatch == match.originalMatch, + orElse: () => throw "No closed match found for undoReplacement", ); _closedMatches.remove(closedMatch); diff --git a/lib/pangea/choreographer/models/it_step.dart b/lib/pangea/choreographer/models/it_step.dart index 258a81cc2..b001e1757 100644 --- a/lib/pangea/choreographer/models/it_step.dart +++ b/lib/pangea/choreographer/models/it_step.dart @@ -3,48 +3,35 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import '../constants/choreo_constants.dart'; -class ITStep { +class CompletedITStep { final List continuances; - final int? chosen; - final String? customInput; - final bool showAlternativeTranslationOption = false; + final int chosen; - ITStep( + const CompletedITStep( this.continuances, { - this.chosen, - this.customInput, - }) { - if (chosen == null && customInput == null) { - throw Exception("ITStep must have either chosen or customInput"); - } - if (chosen != null && customInput != null) { - throw Exception("ITStep must have only chosen or customInput"); - } - } + required this.chosen, + }); Map toJson() { final Map data = {}; data['continuances'] = continuances.map((e) => e.toJson(true)).toList(); data['chosen'] = chosen; - data['custom_input'] = customInput; return data; } - factory ITStep.fromJson(Map json) { + factory CompletedITStep.fromJson(Map json) { final List continuances = []; for (final Map continuance in json['continuances']) { continuances.add(Continuance.fromJson(continuance)); } - return ITStep( + return CompletedITStep( continuances, chosen: json['chosen'], - customInput: json['custom_input'], ); } Continuance? get chosenContinuance { - if (chosen == null) return null; - return continuances[chosen!]; + return continuances[chosen]; } } diff --git a/lib/pangea/choreographer/models/span_data.dart b/lib/pangea/choreographer/models/span_data.dart index 21a0fb75d..e55ee212c 100644 --- a/lib/pangea/choreographer/models/span_data.dart +++ b/lib/pangea/choreographer/models/span_data.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:diacritic/diacritic.dart'; -import 'package:fluffychat/pangea/choreographer/utils/text_normalization_util.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import '../enums/span_choice_type.dart'; import '../enums/span_data_type.dart'; @@ -136,8 +137,33 @@ class SpanData { final errorSpan = fullText.characters.skip(offset).take(length).toString(); return correctChoice != null && - TextNormalizationUtil.normalizeString(correctChoice) == - TextNormalizationUtil.normalizeString(errorSpan); + _normalizeString(correctChoice) == _normalizeString(errorSpan); + } + + String _normalizeString(String input) { + try { + // Step 1: Remove diacritics (accents) + String normalized = removeDiacritics(input); + normalized = normalized.replaceAll(RegExp(r'[^\x00-\x7F]'), ''); + + // Step 2: Remove punctuation + normalized = normalized.replaceAll(RegExp(r'[^\w\s]'), ''); + + // Step 3: Convert to lowercase + normalized = normalized.toLowerCase(); + + // Step 4: Trim and normalize whitespace + normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim(); + + return normalized.isEmpty ? input : normalized; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'input': input}, + ); + return input; + } } @override diff --git a/lib/pangea/choreographer/utils/input_paste_listener.dart b/lib/pangea/choreographer/utils/input_paste_listener.dart deleted file mode 100644 index 5aa234c6a..000000000 --- a/lib/pangea/choreographer/utils/input_paste_listener.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart'; -import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; - -class InputPasteListener { - final PangeaTextController controller; - final Function(String) onPaste; - - String _currentText = ''; - - InputPasteListener( - this.controller, - this.onPaste, - ) { - controller.addListener(() { - final difference = - controller.text.characters.length - _currentText.characters.length; - if (difference > 1 && controller.editType == EditType.keyboard) { - onPaste( - controller.text.characters - .getRange( - _currentText.characters.length, - controller.text.characters.length, - ) - .join(), - ); - } - _currentText = controller.text; - }); - } -} diff --git a/lib/pangea/choreographer/utils/match_style_util.dart b/lib/pangea/choreographer/utils/match_style_util.dart deleted file mode 100644 index 072240601..000000000 --- a/lib/pangea/choreographer/utils/match_style_util.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/choreographer/constants/match_rule_ids.dart'; -import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; -import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; - -class MatchStyleUtil { - static TextStyle underlineStyle(Color color) => TextStyle( - decoration: TextDecoration.underline, - decorationColor: color, - decorationThickness: 5, - ); - - static Color _underlineColor(PangeaMatch match) { - if (match.status == PangeaMatchStatus.automatic) { - return const Color.fromARGB(187, 132, 96, 224); - } - - switch (match.match.rule?.id ?? "unknown") { - case MatchRuleIds.interactiveTranslation: - return const Color.fromARGB(187, 132, 96, 224); - case MatchRuleIds.tokenNeedsTranslation: - case MatchRuleIds.tokenSpanNeedsTranslation: - return const Color.fromARGB(186, 255, 132, 0); - default: - return const Color.fromARGB(149, 255, 17, 0); - } - } - - static TextStyle textStyle( - PangeaMatch match, - TextStyle? existingStyle, - bool isOpenMatch, - ) { - double opacityFactor = 1.0; - if (!isOpenMatch) { - opacityFactor = 0.2; - } - - final alpha = (255 * opacityFactor).round(); - final style = underlineStyle(_underlineColor(match).withAlpha(alpha)); - return existingStyle?.merge(style) ?? style; - } -} diff --git a/lib/pangea/choreographer/utils/text_normalization_util.dart b/lib/pangea/choreographer/utils/text_normalization_util.dart deleted file mode 100644 index 64eec5dda..000000000 --- a/lib/pangea/choreographer/utils/text_normalization_util.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:diacritic/diacritic.dart'; - -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; - -class TextNormalizationUtil { - static String normalizeString(String input) { - try { - // Step 1: Remove diacritics (accents) - String normalized = removeDiacritics(input); - normalized = normalized.replaceAll(RegExp(r'[^\x00-\x7F]'), ''); - - // Step 2: Remove punctuation - normalized = normalized.replaceAll(RegExp(r'[^\w\s]'), ''); - - // Step 3: Convert to lowercase - normalized = normalized.toLowerCase(); - - // Step 4: Trim and normalize whitespace - normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim(); - - return normalized.isEmpty ? input : normalized; - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: {'input': input}, - ); - return input; - } - } -} diff --git a/lib/pangea/choreographer/widgets/igc/card_error_widget.dart b/lib/pangea/choreographer/widgets/igc/card_error_widget.dart index 029d2621b..234c3f2f8 100644 --- a/lib/pangea/choreographer/widgets/igc/card_error_widget.dart +++ b/lib/pangea/choreographer/widgets/igc/card_error_widget.dart @@ -3,23 +3,16 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; class CardErrorWidget extends StatelessWidget { final String error; - final Choreographer? choreographer; - final int? offset; final double maxWidth; - final double padding; const CardErrorWidget({ super.key, required this.error, - this.choreographer, - this.offset, this.maxWidth = 275, - this.padding = 8, }); @override @@ -31,7 +24,7 @@ class CardErrorWidget extends StatelessWidget { ); return Container( - padding: EdgeInsets.all(padding), + padding: const EdgeInsets.all(8.0), constraints: BoxConstraints(maxWidth: maxWidth), child: Column( crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/pangea/choreographer/widgets/igc/language_mismatch_popup.dart b/lib/pangea/choreographer/widgets/igc/language_mismatch_popup.dart index 62edb4798..3fde694c5 100644 --- a/lib/pangea/choreographer/widgets/igc/language_mismatch_popup.dart +++ b/lib/pangea/choreographer/widgets/igc/language_mismatch_popup.dart @@ -3,20 +3,17 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/card_header.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class LanguageMismatchPopup extends StatelessWidget { final String targetLanguage; - final Choreographer choreographer; final VoidCallback onUpdate; const LanguageMismatchPopup({ super.key, required this.targetLanguage, - required this.choreographer, required this.onUpdate, }); diff --git a/lib/pangea/choreographer/widgets/igc/paywall_card.dart b/lib/pangea/choreographer/widgets/igc/paywall_card.dart index 0f534b7ac..d27c3d5e0 100644 --- a/lib/pangea/choreographer/widgets/igc/paywall_card.dart +++ b/lib/pangea/choreographer/widgets/igc/paywall_card.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/card_header.dart'; @@ -10,15 +9,13 @@ import 'package:fluffychat/pangea/subscription/repo/subscription_management_repo import 'package:fluffychat/widgets/matrix.dart'; class PaywallCard extends StatelessWidget { - final ChatController chatController; const PaywallCard({ super.key, - required this.chatController, }); static Future show( BuildContext context, - ChatController chatController, + String targetId, ) async { if (!MatrixState .pangeaController.subscriptionController.shouldShowPaywall) { @@ -28,12 +25,10 @@ class PaywallCard extends StatelessWidget { await SubscriptionManagementRepo.setDismissedPaywall(); OverlayUtil.showPositionedCard( context: context, - cardToShow: PaywallCard( - chatController: chatController, - ), + cardToShow: const PaywallCard(), maxHeight: 325, maxWidth: 325, - transformTargetId: chatController.choreographer.inputTransformTargetKey, + transformTargetId: targetId, ); } @@ -91,7 +86,6 @@ class PaywallCard extends StatelessWidget { width: double.infinity, child: TextButton( onPressed: () { - chatController.clearSelectedEvents(); MatrixState.pangeaController.subscriptionController .showPaywall(context); }, diff --git a/lib/pangea/choreographer/widgets/igc/span_card.dart b/lib/pangea/choreographer/widgets/igc/span_card.dart index 85376de78..267045fbf 100644 --- a/lib/pangea/choreographer/widgets/igc/span_card.dart +++ b/lib/pangea/choreographer/widgets/igc/span_card.dart @@ -1,18 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; import 'package:fluffychat/pangea/choreographer/enums/span_choice_type.dart'; import 'package:fluffychat/pangea/choreographer/enums/span_data_type.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import '../../../../widgets/matrix.dart'; import '../../../bot/widgets/bot_face_svg.dart'; import '../choice_array.dart'; import 'why_button.dart'; +// CTODO refactor class SpanCard extends StatefulWidget { final PangeaMatchState match; final Choreographer choreographer; @@ -34,11 +40,6 @@ class SpanCardState extends State { @override void initState() { super.initState(); - if (widget.match.updatedMatch.isITStart == true) { - _onITStart(); - return; - } - getSpanDetails(); } @@ -60,7 +61,7 @@ class SpanCardState extends State { fetchingData = true; }); - await widget.choreographer.igc.setSpanDetails( + await widget.choreographer.fetchSpanDetails( match: widget.match, force: force, ); @@ -70,12 +71,6 @@ class SpanCardState extends State { } } - void _onITStart() { - if (widget.choreographer.itEnabled) { - widget.choreographer.onITStart(widget.match); - } - } - void _onChoiceSelect(int index) { widget.match.selectChoice(index); setState( @@ -86,25 +81,53 @@ class SpanCardState extends State { } Future _onAcceptReplacement() async { - await widget.choreographer.onAcceptReplacement( - match: widget.match, - ); + try { + widget.choreographer.onAcceptReplacement( + match: widget.match, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "match": widget.match.toJson(), + }, + ); + widget.choreographer.clearMatches(e); + return; + } + _showFirstMatch(); } void _onIgnoreMatch() { - Future.delayed( - Duration.zero, - () { - widget.choreographer.onIgnoreMatch(match: widget.match); - _showFirstMatch(); - }, - ); + try { + widget.choreographer.onIgnoreMatch(match: widget.match); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "match": widget.match.toJson(), + }, + ); + widget.choreographer.clearMatches(e); + return; + } + + _showFirstMatch(); } void _showFirstMatch() { - if (widget.choreographer.igc.canShowFirstMatch) { - widget.choreographer.igc.showFirstMatch(context); + if (widget.choreographer.canShowFirstIGCMatch) { + final igcMatch = widget.choreographer.igc.onShowFirstMatch(); + OverlayUtil.showIGCMatch( + igcMatch!, + widget.choreographer, + context, + ); } else { MatrixState.pAnyState.closeOverlay(); } diff --git a/lib/pangea/choreographer/widgets/igc/word_data_card.dart b/lib/pangea/choreographer/widgets/igc/word_data_card.dart index 8ea42b171..2ec66cb32 100644 --- a/lib/pangea/choreographer/widgets/igc/word_data_card.dart +++ b/lib/pangea/choreographer/widgets/igc/word_data_card.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart'; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; @@ -18,23 +17,19 @@ import 'package:fluffychat/widgets/matrix.dart'; import 'card_error_widget.dart'; class WordDataCard extends StatefulWidget { - final bool hasInfo; final String word; final String fullText; final String? choiceFeedback; final String wordLang; final String fullTextLang; - final Room room; const WordDataCard({ super.key, required this.word, - required this.wordLang, - required this.hasInfo, required this.fullText, - required this.fullTextLang, - required this.room, this.choiceFeedback, + required this.wordLang, + required this.fullTextLang, }); @override diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index feea6fb3f..137b40d8c 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -4,10 +4,14 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/it_controller.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/word_data_card.dart'; @@ -30,11 +34,7 @@ class ITBar extends StatefulWidget { } class ITBarState extends State with SingleTickerProviderStateMixin { - ITController get itController => widget.choreographer.itController; - StreamSubscription? _choreoSub; - bool showedClickInstruction = false; - late AnimationController _controller; late Animation _animation; bool wasOpen = false; @@ -44,24 +44,28 @@ class ITBarState extends State with SingleTickerProviderStateMixin { super.initState(); // Rebuild the widget each time there's an update from choreo. - _choreoSub = widget.choreographer.stateStream.stream.listen((_) { - if (itController.willOpen != wasOpen) { - itController.willOpen ? _controller.forward() : _controller.reverse(); + widget.choreographer.addListener(() { + if (widget.choreographer.isITOpen != wasOpen) { + widget.choreographer.isITOpen + ? _controller.forward() + : _controller.reverse(); } - wasOpen = itController.willOpen; + wasOpen = widget.choreographer.isITOpen; setState(() {}); }); - wasOpen = itController.willOpen; + wasOpen = widget.choreographer.isITOpen; _controller = AnimationController( - duration: itController.animationSpeed, + duration: const Duration(milliseconds: 300), vsync: this, ); _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); // Start in the correct state - itController.willOpen ? _controller.forward() : _controller.reverse(); + widget.choreographer.isITOpen + ? _controller.forward() + : _controller.reverse(); } bool get showITInstructionsTooltip { @@ -75,18 +79,10 @@ class ITBarState extends State with SingleTickerProviderStateMixin { bool get showTranslationsChoicesTooltip { return !showedClickInstruction && !showITInstructionsTooltip && - !itController.choreographer.isFetching && - !itController.isLoading && - !itController.isEditingSourceText && - !itController.isTranslationDone && - itController.currentITStep != null && - itController.currentITStep!.continuances.isNotEmpty; - } - - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); + !widget.choreographer.isFetching && + !widget.choreographer.isEditingSourceText && + !widget.choreographer.isITDone && + widget.choreographer.itStepContinuances?.isNotEmpty == true; } final double iconDimension = 36; @@ -130,7 +126,7 @@ class ITBarState extends State with SingleTickerProviderStateMixin { mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (itController.isEditingSourceText) + if (widget.choreographer.isEditingSourceText) Expanded( child: Padding( padding: const EdgeInsets.only( @@ -140,14 +136,14 @@ class ITBarState extends State with SingleTickerProviderStateMixin { ), child: TextField( controller: TextEditingController( - text: itController.sourceText, + text: widget.choreographer.sourceText, ), autofocus: true, enableSuggestions: false, maxLines: null, textInputAction: TextInputAction.send, onSubmitted: - itController.onEditSourceTextSubmit, + widget.choreographer.submitSourceTextEdits, obscureText: false, decoration: const InputDecoration( border: OutlineInputBorder(), @@ -155,24 +151,21 @@ class ITBarState extends State with SingleTickerProviderStateMixin { ), ), ), - if (!itController.isEditingSourceText && - itController.sourceText != null) + if (!widget.choreographer.isEditingSourceText && + widget.choreographer.sourceText != null) SizedBox( width: iconDimension, height: iconDimension, child: IconButton( iconSize: iconSize, color: Theme.of(context).colorScheme.primary, - onPressed: () { - if (itController.nextITStep != null) { - itController.setIsEditingSourceText(true); - } - }, + onPressed: () => widget.choreographer + .setEditingSourceText(true), icon: const Icon(Icons.edit_outlined), // iconSize: 20, ), ), - if (!itController.isEditingSourceText) + if (!widget.choreographer.isEditingSourceText) SizedBox( width: iconDimension, height: iconDimension, @@ -195,22 +188,23 @@ class ITBarState extends State with SingleTickerProviderStateMixin { color: Theme.of(context).colorScheme.primary, icon: const Icon(Icons.close_outlined), onPressed: () { - itController.isEditingSourceText - ? itController.setIsEditingSourceText(false) - : itController.closeIT(); + widget.choreographer.isEditingSourceText + ? widget.choreographer + .setEditingSourceText(false) + : widget.choreographer.closeIT(); }, ), ), ], ), - if (!itController.isEditingSourceText) + if (!widget.choreographer.isEditingSourceText) Padding( padding: const EdgeInsets.only(top: 8.0), - child: !itController.willOpen + child: !widget.choreographer.isITOpen ? const SizedBox() - : itController.sourceText != null + : widget.choreographer.sourceText != null ? Text( - itController.sourceText!, + widget.choreographer.sourceText!, textAlign: TextAlign.center, ) : const LinearProgressIndicator(), @@ -220,13 +214,15 @@ class ITBarState extends State with SingleTickerProviderStateMixin { padding: const EdgeInsets.symmetric(horizontal: 4.0), constraints: const BoxConstraints(minHeight: 80), child: AnimatedSize( - duration: itController.animationSpeed, + duration: const Duration(milliseconds: 300), child: Center( - child: itController.choreographer.errorService.isError - ? ITError(controller: itController) - : itController.isTranslationDone + child: widget.choreographer.errorService.isError + ? ITError(choreographer: widget.choreographer) + : widget.choreographer.isITDone ? const SizedBox() - : ITChoices(controller: itController), + : ITChoices( + choreographer: widget.choreographer, + ), ), ), ), @@ -242,39 +238,19 @@ class ITBarState extends State with SingleTickerProviderStateMixin { } class ITChoices extends StatelessWidget { + final Choreographer choreographer; const ITChoices({ super.key, - required this.controller, + required this.choreographer, }); - // final choices = [ - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // "we need a really long translation to see what's going to happen with that. it should probably have multiple sentences so that we can see what happens there.", - // ]; - - final ITController controller; - - String? get sourceText { - if ((controller.sourceText == null || controller.sourceText!.isEmpty)) { - ErrorHandler.logError( - m: "null source text in ItChoices", - data: {}, - ); - } - return controller.sourceText; - } - void showCard( BuildContext context, int index, [ Color? borderColor, String? choiceFeedback, ]) { - if (controller.currentITStep == null) { + if (choreographer.itStepContinuances == null) { ErrorHandler.logError( m: "currentITStep is null in showCard", s: StackTrace.current, @@ -285,41 +261,34 @@ class ITChoices extends StatelessWidget { return; } - controller.choreographer.chatController.inputFocus.unfocus(); + final text = choreographer.itStepContinuances![index].text; + choreographer.chatController.inputFocus.unfocus(); MatrixState.pAnyState.closeOverlay("it_feedback_card"); OverlayUtil.showPositionedCard( context: context, cardToShow: choiceFeedback == null ? WordDataCard( - word: controller.currentITStep!.continuances[index].text, - wordLang: controller.targetLangCode, - fullText: sourceText ?? controller.choreographer.currentText, - fullTextLang: sourceText != null - ? controller.sourceLangCode - : controller.targetLangCode, - // IMPORTANT COMMENT TO KEEP: We're going to forace hasInfo to false for now - // because we don't want to show the word data card for correct choices and the contextual definition - // for incorrect choices. This gives away the answer (if you're Kel at least). - // The reason hasInfo is false for incorrect choices is that we're not includng the tokens for distractors. - // Correct choices will have the tokens, but we don't want to show something different for them. - // hasInfo: controller.currentITStep!.continuances[index].hasInfo, - hasInfo: false, + word: text, + wordLang: choreographer.l2LangCode!, + fullText: choreographer.sourceText ?? choreographer.currentText, + fullTextLang: choreographer.sourceText != null + ? choreographer.l1LangCode! + : choreographer.l2LangCode!, choiceFeedback: choiceFeedback, - room: controller.choreographer.chatController.room, ) : ITFeedbackCard( req: FullTextTranslationRequestModel( - text: controller.currentITStep!.continuances[index].text, - tgtLang: controller.sourceLangCode, - userL1: controller.sourceLangCode, - userL2: controller.targetLangCode, + text: text, + tgtLang: choreographer.l2LangCode!, + userL1: choreographer.l1LangCode!, + userL2: choreographer.l2LangCode!, ), choiceFeedback: choiceFeedback, ), maxHeight: 300, maxWidth: 300, borderColor: borderColor, - transformTargetId: controller.choreographer.itBarTransformTargetKey, + transformTargetId: choreographer.itBarTransformTargetKey, isScrollable: choiceFeedback == null, overlayKey: "it_feedback_card", ignorePointer: true, @@ -328,12 +297,41 @@ class ITChoices extends StatelessWidget { void selectContinuance(int index, BuildContext context) { MatrixState.pAnyState.closeOverlay("it_feedback_card"); - final Continuance continuance = - controller.currentITStep!.continuances[index]; + Continuance continuance; + try { + continuance = choreographer.onSelectContinuance(index); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "index": index, + }, + ); + choreographer.closeIT(); + return; + } + if (continuance.level == 1) { Future.delayed( const Duration(milliseconds: 500), - () => controller.selectTranslation(index), + () { + try { + choreographer.onAcceptContinuance(index); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "index": index, + }, + ); + choreographer.closeIT(); + return; + } + }, ); } else { showCard( @@ -343,20 +341,16 @@ class ITChoices extends StatelessWidget { continuance.feedbackText(context), ); } - controller.currentITStep!.continuances[index] = continuance.copyWith( - wasClicked: true, - ); - controller.choreographer.setState(); } @override Widget build(BuildContext context) { try { - if (controller.isEditingSourceText) { + if (choreographer.isEditingSourceText) { return const SizedBox(); } - if (controller.currentITStep == null) { - return controller.willOpen + if (choreographer.itStepContinuances == null) { + return choreographer.isITOpen ? CircularProgressIndicator( strokeWidth: 2.0, color: Theme.of(context).colorScheme.primary, @@ -364,11 +358,11 @@ class ITChoices extends StatelessWidget { : const SizedBox(); } return ChoicesArray( - id: controller.currentITStep.hashCode.toString(), - isLoading: controller.isLoading || - controller.choreographer.isFetching || - controller.currentITStep == null, - choices: controller.currentITStep!.continuances.map((e) { + id: Object.hashAll(choreographer.itStepContinuances!).toString(), + isLoading: choreographer.isFetching || + choreographer.itStepContinuances == null, + choices: choreographer.itStepContinuances!.map((e) { + debugPrint("WAS CLICKED: ${e.wasClicked}"); try { return Choice( text: e.text.trim(), @@ -383,8 +377,8 @@ class ITChoices extends StatelessWidget { onPressed: (value, index) => selectContinuance(index, context), onLongPress: (value, index) => showCard(context, index), selectedChoiceIndex: null, - langCode: controller.choreographer.pangeaController.languageController - .activeL2Code(), + langCode: + choreographer.pangeaController.languageController.activeL2Code(), ); } catch (e) { debugger(when: kDebugMode); @@ -394,8 +388,11 @@ class ITChoices extends StatelessWidget { } class ITError extends StatelessWidget { - final ITController controller; - const ITError({super.key, required this.controller}); + final Choreographer choreographer; + const ITError({ + super.key, + required this.choreographer, + }); @override Widget build(BuildContext context) { @@ -413,10 +410,7 @@ class ITError extends StatelessWidget { ), ), IconButton( - onPressed: () { - controller.closeIT(); - controller.choreographer.errorService.resetError(); - }, + onPressed: choreographer.closeIT, icon: const Icon( Icons.close, size: 20, diff --git a/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart b/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart index 94d01c82a..1866bee74 100644 --- a/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart +++ b/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; diff --git a/lib/pangea/choreographer/widgets/send_button.dart b/lib/pangea/choreographer/widgets/send_button.dart index 6f8b15e5b..8452bfa15 100644 --- a/lib/pangea/choreographer/widgets/send_button.dart +++ b/lib/pangea/choreographer/widgets/send_button.dart @@ -1,59 +1,65 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; import '../../../pages/chat/chat.dart'; -class ChoreographerSendButton extends StatefulWidget { +class ChoreographerSendButton extends StatelessWidget { const ChoreographerSendButton({ super.key, required this.controller, }); final ChatController controller; - @override - State createState() => - ChoreographerSendButtonState(); -} - -class ChoreographerSendButtonState extends State { - StreamSubscription? _choreoSub; - - @override - void initState() { - // Rebuild the widget each time there's an update from - // choreo. This keeps the spin up-to-date. - _choreoSub = widget.controller.choreographer.stateStream.stream.listen((_) { - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); + Future _onPressed(BuildContext context) async { + controller.choreographer.onClickSend(); + try { + await controller.choreographer.send(); + } on ShowPaywallException { + PaywallCard.show( + context, + controller.choreographer.inputTransformTargetKey, + ); + } on OpenMatchesException { + if (controller.choreographer.firstIGCMatch != null) { + OverlayUtil.showIGCMatch( + controller.choreographer.firstIGCMatch!, + controller.choreographer, + context, + ); + } + } } @override Widget build(BuildContext context) { - return Container( - height: 56, - alignment: Alignment.center, - child: IconButton( - icon: const Icon(Icons.send_outlined), - color: - widget.controller.choreographer.assistanceState.stateColor(context), - onPressed: widget.controller.choreographer.isFetching - ? null - : () { - widget.controller.choreographer.incrementTimesClicked(); - widget.controller.choreographer.send(context); - }, - tooltip: L10n.of(context).send, - ), + return ListenableBuilder( + listenable: controller.choreographer, + builder: (context, _) { + return ValueListenableBuilder( + valueListenable: controller.choreographer.textController, + builder: (context, _, __) { + return Container( + height: 56, + alignment: Alignment.center, + child: IconButton( + icon: const Icon(Icons.send_outlined), + color: controller.choreographer.assistanceState + .stateColor(context), + onPressed: controller.choreographer.isFetching + ? null + : () => _onPressed(context), + tooltip: L10n.of(context).send, + ), + ); + }, + ); + }, ); } } diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart index 556c6b26d..dc0b174d9 100644 --- a/lib/pangea/choreographer/widgets/start_igc_button.dart +++ b/lib/pangea/choreographer/widgets/start_igc_button.dart @@ -5,8 +5,11 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; import '../../../pages/chat/chat.dart'; @@ -27,7 +30,6 @@ class StartIGCButtonState extends State AssistanceState get assistanceState => widget.controller.choreographer.assistanceState; AnimationController? _controller; - StreamSubscription? _choreoListener; AssistanceState? _prevState; @override @@ -36,19 +38,17 @@ class StartIGCButtonState extends State vsync: this, duration: const Duration(seconds: 2), ); - _choreoListener = widget.controller.choreographer.stateStream.stream - .listen(_updateSpinnerState); + widget.controller.choreographer.addListener(_updateSpinnerState); super.initState(); } @override void dispose() { _controller?.dispose(); - _choreoListener?.cancel(); super.dispose(); } - void _updateSpinnerState(_) { + void _updateSpinnerState() { if (_prevState != AssistanceState.fetching && assistanceState == AssistanceState.fetching) { _controller?.repeat(); @@ -62,8 +62,14 @@ class StartIGCButtonState extends State } void _showFirstMatch() { - if (widget.controller.choreographer.igc.canShowFirstMatch) { - widget.controller.choreographer.igc.showFirstMatch(context); + if (widget.controller.choreographer.canShowFirstIGCMatch) { + final match = widget.controller.choreographer.igc.onShowFirstMatch(); + if (match == null) return; + OverlayUtil.showIGCMatch( + match, + widget.controller.choreographer, + context, + ); } } @@ -79,7 +85,10 @@ class StartIGCButtonState extends State Future _onTap() async { switch (assistanceState) { case AssistanceState.noSub: - await PaywallCard.show(context, widget.controller); + await PaywallCard.show( + context, + widget.controller.choreographer.inputTransformTargetKey, + ); return; case AssistanceState.noMessage: showDialog( @@ -92,8 +101,16 @@ class StartIGCButtonState extends State if (widget.controller.shouldShowLanguageMismatchPopup) { widget.controller.showLanguageMismatchPopup(); } else { - await widget.controller.choreographer.getLanguageHelp(manual: true); - _showFirstMatch(); + final igcMatch = + await widget.controller.choreographer.requestLanguageAssistance(); + + if (igcMatch != null) { + OverlayUtil.showIGCMatch( + igcMatch, + widget.controller.choreographer, + context, + ); + } } return; case AssistanceState.fetched: @@ -120,66 +137,70 @@ class StartIGCButtonState extends State @override Widget build(BuildContext context) { - final icon = Icon( - size: 36, - Icons.autorenew_rounded, - color: assistanceState.stateColor(context), - ); - - return Tooltip( - message: _enableFeedback ? L10n.of(context).check : "", - child: Material( - elevation: _enableFeedback ? 4.0 : 0.0, - borderRadius: BorderRadius.circular(99.0), - shadowColor: Theme.of(context).colorScheme.surface.withAlpha(128), - child: InkWell( - enableFeedback: _enableFeedback, - onTap: _enableFeedback ? _onTap : null, - customBorder: const CircleBorder(), - onLongPress: _enableFeedback - ? () => showDialog( - context: context, - builder: (c) => const SettingsLearning(), - barrierDismissible: false, - ) - : null, - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedContainer( - height: 40.0, - width: 40.0, - duration: FluffyThemes.animationDuration, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _backgroundColor, - ), + return ValueListenableBuilder( + valueListenable: widget.controller.choreographer.textController, + builder: (context, _, __) { + final icon = Icon( + size: 36, + Icons.autorenew_rounded, + color: assistanceState.stateColor(context), + ); + return Tooltip( + message: _enableFeedback ? L10n.of(context).check : "", + child: Material( + elevation: _enableFeedback ? 4.0 : 0.0, + borderRadius: BorderRadius.circular(99.0), + shadowColor: Theme.of(context).colorScheme.surface.withAlpha(128), + child: InkWell( + enableFeedback: _enableFeedback, + onTap: _enableFeedback ? _onTap : null, + customBorder: const CircleBorder(), + onLongPress: _enableFeedback + ? () => showDialog( + context: context, + builder: (c) => const SettingsLearning(), + barrierDismissible: false, + ) + : null, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedContainer( + height: 40.0, + width: 40.0, + duration: FluffyThemes.animationDuration, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _backgroundColor, + ), + ), + _controller != null + ? RotationTransition( + turns: Tween(begin: 0.0, end: math.pi * 2) + .animate(_controller!), + child: icon, + ) + : icon, + AnimatedContainer( + width: 20, + height: 20, + duration: FluffyThemes.animationDuration, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _backgroundColor, + ), + ), + Icon( + size: 16, + Icons.check, + color: assistanceState.stateColor(context), + ), + ], ), - _controller != null - ? RotationTransition( - turns: Tween(begin: 0.0, end: math.pi * 2) - .animate(_controller!), - child: icon, - ) - : icon, - AnimatedContainer( - width: 20, - height: 20, - duration: FluffyThemes.animationDuration, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _backgroundColor, - ), - ), - Icon( - size: 16, - Icons.check, - color: assistanceState.stateColor(context), - ), - ], + ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index a18be4332..2b0b61bd5 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -3,6 +3,10 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_ui_extension.dart'; +import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/widgets/anchored_overlay_widget.dart'; import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; @@ -217,6 +221,28 @@ class OverlayUtil { } } + static void showIGCMatch( + PangeaMatchState match, + Choreographer choreographer, + BuildContext context, + ) { + MatrixState.pAnyState.closeAllOverlays(); + showPositionedCard( + overlayKey: + "span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}", + context: context, + cardToShow: SpanCard( + match: match, + choreographer: choreographer, + ), + maxHeight: 325, + maxWidth: 325, + transformTargetId: choreographer.inputTransformTargetKey, + ignorePointer: true, + isScrollable: false, + ); + } + static void showTutorialOverlay( BuildContext context, { required Widget overlayContent, diff --git a/lib/pangea/subscription/controllers/subscription_controller.dart b/lib/pangea/subscription/controllers/subscription_controller.dart index cca7bf4e0..0f65de6fa 100644 --- a/lib/pangea/subscription/controllers/subscription_controller.dart +++ b/lib/pangea/subscription/controllers/subscription_controller.dart @@ -27,6 +27,7 @@ import 'package:fluffychat/pangea/subscription/utils/subscription_app_id.dart'; import 'package:fluffychat/pangea/subscription/widgets/subscription_paywall.dart'; import 'package:fluffychat/pangea/user/controllers/user_controller.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/matrix.dart'; enum SubscriptionStatus { loading, @@ -267,6 +268,8 @@ class SubscriptionController extends BaseController { return; } if (isSubscribed == null || isSubscribed!) return; + + MatrixState.pAnyState.closeAllOverlays(); await showModalBottomSheet( isScrollControlled: true, useRootNavigator: !PlatformInfos.isMobile,