From 03a5bed450297e1edbdf58eeb7b46e07dc8a7191 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:13:57 -0400 Subject: [PATCH] chore: allow users to undo autocorrection of normalization errors (#2538) --- .../controllers/choreographer.dart | 27 +++ .../models/igc_text_data_model.dart | 162 ++++++++++++++++-- .../widgets/igc/autocorrect_popup.dart | 35 ++++ .../widgets/igc/pangea_text_controller.dart | 8 +- 4 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 lib/pangea/choreographer/widgets/igc/autocorrect_popup.dart diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 55b001619..ad368103a 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -425,6 +425,33 @@ class Choreographer { } } + void onUndoReplacement(PangeaMatch match) { + try { + igc.igcTextData?.undoReplacement(match); + + choreoRecord.choreoSteps.removeWhere( + (step) => step.acceptedOrIgnoredMatch == match, + ); + + _textController.setSystemText( + igc.igcTextData!.originalInput, + EditType.igc, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "igctextData": igc.igcTextData?.toJson(), + "match": match.toJson(), + }, + ); + } finally { + MatrixState.pAnyState.closeOverlay(); + setState(); + } + } + void acceptNormalizationMatches() { for (int i = 0; i < igc.igcTextData!.matches.length; i++) { final isNormalizationError = diff --git a/lib/pangea/choreographer/models/igc_text_data_model.dart b/lib/pangea/choreographer/models/igc_text_data_model.dart index 806c72369..ac6613b58 100644 --- a/lib/pangea/choreographer/models/igc_text_data_model.dart +++ b/lib/pangea/choreographer/models/igc_text_data_model.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -8,8 +9,10 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/igc/autocorrect_popup.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -142,6 +145,55 @@ class IGCTextData { } } + void undoReplacement(PangeaMatch match) async { + if (match.match.choices == null) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "pangeaMatch.match.choices is null in undoReplacement", + data: { + "match": match.match.toJson(), + }, + ); + return; + } + + if (!match.match.choices!.any((c) => c.isBestCorrection)) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "pangeaMatch.match.choices has no best correction in undoReplacement", + data: { + "match": match.match.toJson(), + }, + ); + return; + } + + final bestCorrection = + match.match.choices!.firstWhere((c) => c.isBestCorrection).value; + + final String replacement = match.match.fullText.characters + .getRange( + match.match.offset, + match.match.offset + match.match.length, + ) + .toString(); + + final newStart = originalInput.characters.take(match.match.offset); + final newEnd = originalInput.characters.skip( + match.match.offset + bestCorrection.characters.length, + ); + final fullText = newStart + replacement.characters + newEnd; + originalInput = fullText.toString(); + + for (final remainingMatch in matches) { + remainingMatch.match.fullText = originalInput; + if (remainingMatch.match.offset > match.match.offset) { + remainingMatch.match.offset += + match.match.length - bestCorrection.characters.length; + } + } + } + List matchIndicesByOffset(int offset) { final List matchesForOffset = []; for (final (index, match) in matches.indexed) { @@ -198,17 +250,25 @@ class IGCTextData { //PTODO - handle multitoken spans /// Returns a list of [TextSpan]s used to display the text in the input field /// with the appropriate styling for each error match. - List constructTokenSpan({ - ChoreoRecordStep? choreoStep, + List constructTokenSpan({ + required List choreoSteps, + void Function(PangeaMatch)? onUndo, TextStyle? defaultStyle, }) { - final stepMatch = choreoStep?.acceptedOrIgnoredMatch; - final List textSpanMatches = List.from(matches); - if (stepMatch != null && stepMatch.status == PangeaMatchStatus.automatic) { - textSpanMatches.add(stepMatch); - } + final automaticMatches = choreoSteps + .where( + (step) => + step.acceptedOrIgnoredMatch?.status == + PangeaMatchStatus.automatic, + ) + .map((step) => step.acceptedOrIgnoredMatch) + .whereType() + .toList(); - final List items = []; + final List textSpanMatches = List.from(matches); + textSpanMatches.addAll(automaticMatches); + + final List items = []; if (loading) { return [ @@ -243,19 +303,83 @@ class IGCTextData { // if the pointer is in a match, then add that match to items // and then move the pointer to the end of the match range final PangeaMatch match = textSpanMatches[matchIndex]; - items.add( - getSpanItem( - start: match.match.offset, - end: match.match.offset + match.match.length, - style: match.textStyle( - matchIndex, - _openMatchIndex, - defaultStyle, - ), - ), + final style = match.textStyle( + matchIndex, + _openMatchIndex, + defaultStyle, ); + if (match.status == PangeaMatchStatus.automatic) { + final span = originalInput.characters + .getRange( + match.match.offset, + match.match.offset + + (match.match.choices + ?.firstWhere((c) => c.isBestCorrection) + .value + .characters + .length ?? + match.match.length), + ) + .toString(); - currentIndex = match.match.offset + match.match.length; + final originalText = match.match.fullText.characters + .getRange( + match.match.offset, + match.match.offset + match.match.length, + ) + .toString(); + + items.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: CompositedTransformTarget( + link: MatrixState.pAnyState + .layerLinkAndKey("autocorrection_$matchIndex") + .link, + child: Builder( + builder: (context) { + return RichText( + key: MatrixState.pAnyState + .layerLinkAndKey("autocorrection_$matchIndex") + .key, + text: TextSpan( + text: span, + style: style, + recognizer: TapGestureRecognizer() + ..onTap = () { + OverlayUtil.showOverlay( + context: context, + child: AutocorrectPopup( + originalText: originalText, + onUndo: () => onUndo?.call(match), + ), + transformTargetId: "autocorrection_$matchIndex", + ); + }, + ), + ); + }, + ), + ), + ), + ); + + currentIndex = match.match.offset + + (match.match.choices + ?.firstWhere((c) => c.isBestCorrection) + .value + .length ?? + match.match.length); + } else { + items.add( + getSpanItem( + start: match.match.offset, + end: match.match.offset + match.match.length, + style: style, + ), + ); + currentIndex = match.match.offset + match.match.length; + } } else { // otherwise, if the pointer is not at a match, then add all the text // until the next match (or, if there is not next match, the end of the diff --git a/lib/pangea/choreographer/widgets/igc/autocorrect_popup.dart b/lib/pangea/choreographer/widgets/igc/autocorrect_popup.dart new file mode 100644 index 000000000..263cbeee3 --- /dev/null +++ b/lib/pangea/choreographer/widgets/igc/autocorrect_popup.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class AutocorrectPopup extends StatelessWidget { + final String originalText; + final VoidCallback onUndo; + + const AutocorrectPopup({ + required this.originalText, + required this.onUndo, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: theme.colorScheme.surface.withAlpha(200), + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + Text(originalText), + InkWell( + onTap: onUndo, + child: const Icon(Icons.replay, size: 12), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart index 9da04d969..fd21337eb 100644 --- a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart +++ b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/widgets/igc/paywall_card.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; @@ -175,8 +176,13 @@ class PangeaTextController extends TextEditingController { style: style, children: [ ...choreographer.igc.igcTextData!.constructTokenSpan( - choreoStep: choreoSteps.isNotEmpty ? choreoSteps.last : null, + choreoSteps: choreoSteps.isNotEmpty && + choreoSteps.last.acceptedOrIgnoredMatch?.status == + PangeaMatchStatus.automatic + ? choreoSteps + : [], defaultStyle: style, + onUndo: choreographer.onUndoReplacement, ), TextSpan(text: parts[1], style: style), ],