chore: allow users to undo autocorrection of normalization errors (#2538)

This commit is contained in:
ggurdin 2025-04-23 14:13:57 -04:00 committed by GitHub
parent b25676a58d
commit 03a5bed450
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 212 additions and 20 deletions

View file

@ -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 =

View file

@ -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<int> matchIndicesByOffset(int offset) {
final List<int> 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<TextSpan> constructTokenSpan({
ChoreoRecordStep? choreoStep,
List<InlineSpan> constructTokenSpan({
required List<ChoreoRecordStep> choreoSteps,
void Function(PangeaMatch)? onUndo,
TextStyle? defaultStyle,
}) {
final stepMatch = choreoStep?.acceptedOrIgnoredMatch;
final List<PangeaMatch> 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<PangeaMatch>()
.toList();
final List<TextSpan> items = [];
final List<PangeaMatch> textSpanMatches = List.from(matches);
textSpanMatches.addAll(automaticMatches);
final List<InlineSpan> 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

View file

@ -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),
),
],
),
);
}
}

View file

@ -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),
],