From 0db2c70ef430fde2a6a3e86dbd85160f36461eb3 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 28 Oct 2025 14:26:14 -0400 Subject: [PATCH] immutable data with separate stateful models --- lib/pages/chat/chat.dart | 3 +- .../controllers/choreographer.dart | 147 +++------ .../controllers/igc_controller.dart | 105 +++--- .../controllers/it_controller.dart | 17 +- .../controllers/span_data_controller.dart | 77 ----- .../enums/pangea_match_status.dart | 23 ++ .../choreographer/models/choreo_edit.dart | 45 ++- .../choreographer/models/choreo_record.dart | 23 +- .../models/igc_text_data_model.dart | 304 +++--------------- .../choreographer/models/igc_text_state.dart | 204 ++++++++++++ lib/pangea/choreographer/models/it_step.dart | 65 ++-- .../models/language_detection_model.dart | 6 +- .../models/pangea_match_model.dart | 44 +-- .../models/pangea_match_state.dart | 49 +++ .../choreographer/models/span_data.dart | 217 ++++++++++--- lib/pangea/choreographer/repo/igc_repo.dart | 18 +- .../repo/igc_response_model.dart | 53 +++ ...ive_translation_repo.dart => it_repo.dart} | 12 +- ...quest_model.dart => it_request_model.dart} | 10 +- .../choreographer/repo/span_data_repo.dart | 111 ++----- .../choreographer/repo/span_data_request.dart | 47 +++ .../repo/span_data_response.dart | 26 ++ .../choreographer/utils/match_style_util.dart | 1 + .../utils/pangea_text_controller.dart | 172 +++++++--- .../choreographer/widgets/igc/span_card.dart | 160 +++------ lib/pangea/choreographer/widgets/it_bar.dart | 4 +- .../widgets/start_igc_button.dart | 3 +- .../models/representation_content_model.dart | 2 +- .../constants/language_constants.dart | 2 +- 29 files changed, 1063 insertions(+), 887 deletions(-) delete mode 100644 lib/pangea/choreographer/controllers/span_data_controller.dart create mode 100644 lib/pangea/choreographer/enums/pangea_match_status.dart create mode 100644 lib/pangea/choreographer/models/igc_text_state.dart create mode 100644 lib/pangea/choreographer/models/pangea_match_state.dart create mode 100644 lib/pangea/choreographer/repo/igc_response_model.dart rename lib/pangea/choreographer/repo/{interactive_translation_repo.dart => it_repo.dart} (91%) rename lib/pangea/choreographer/repo/{custom_input_request_model.dart => it_request_model.dart} (90%) create mode 100644 lib/pangea/choreographer/repo/span_data_request.dart create mode 100644 lib/pangea/choreographer/repo/span_data_response.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 2aa780984..396599fc4 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2196,8 +2196,7 @@ class ChatController extends State choreographer: choreographer, onUpdate: () async { await choreographer.getLanguageHelp(manual: true); - final matches = choreographer.igc.igcTextData?.matches; - if (matches?.isNotEmpty == true) { + if (choreographer.igc.canShowFirstMatch) { choreographer.igc.showFirstMatch(context); } }, diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 0b2eeb85d..b64d82cb8 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -10,9 +10,10 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart'; import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.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_model.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/utils/pangea_text_controller.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; @@ -90,7 +91,7 @@ class Choreographer { // could happen if user clicked send button multiple times in a row if (isFetching) return; - if (igc.igcTextData != null && igc.igcTextData!.matches.isNotEmpty) { + if (igc.canShowFirstMatch) { igc.showFirstMatch(context); return; } else if (isRunningIT) { @@ -223,25 +224,25 @@ class Choreographer { ); } - void onITStart(PangeaMatch itMatch) { - if (!itMatch.isITStart) { + void onITStart(PangeaMatchState itMatch) { + if (!itMatch.updatedMatch.isITStart) { throw Exception("this isn't an itStart match!"); } choreoMode = ChoreoMode.it; itController.initializeIT( ITStartData(_textController.text, null), ); - itMatch.status = PangeaMatchStatus.accepted; + translatedText = _textController.text; - - //PTODO - if totally in L1, save tokens, that's good stuff - igc.clear(); - _textController.setSystemText("", EditType.itStart); _initChoreoRecord(); - choreoRecord!.addRecord(_textController.text, match: itMatch); + itMatch.setStatus(PangeaMatchStatus.accepted); + choreoRecord!.addRecord( + _textController.text, + match: itMatch.updatedMatch, + ); } /// Handles any changes to the text input @@ -317,6 +318,7 @@ class Choreographer { return; } + _resetDebounceTimer(); startLoading(); _initChoreoRecord(); @@ -365,9 +367,8 @@ class Choreographer { giveInputFocus(); } - Future onReplacementSelect({ - required int matchIndex, - required int choiceIndex, + Future onAcceptReplacement({ + required PangeaMatchState match, }) async { try { if (igc.igcTextData == null) { @@ -375,52 +376,35 @@ class Choreographer { e: "onReplacementSelect with null igcTextData", s: StackTrace.current, data: { - "matchIndex": matchIndex, - "choiceIndex": choiceIndex, + "match": match.toJson(), }, ); MatrixState.pAnyState.closeOverlay(); return; } - if (igc.igcTextData!.matches[matchIndex].match.choices == null) { + if (match.updatedMatch.match.selectedChoice == null) { ErrorHandler.logError( - e: "onReplacementSelect with null choices", + e: "onReplacementSelect with null selectedChoice", s: StackTrace.current, data: { "igctextData": igc.igcTextData?.toJson(), - "matchIndex": matchIndex, - "choiceIndex": choiceIndex, + "match": match.toJson(), }, ); MatrixState.pAnyState.closeOverlay(); return; } - //if it's the wrong choice, return - // if (!igc.igcTextData!.matches[matchIndex].match.choices![choiceIndex] - // .selected) { - // igc.igcTextData!.matches[matchIndex].match.choices![choiceIndex] - // .selected = true; - // setState(); - // return; - // } - - igc.igcTextData!.matches[matchIndex].match.choices![choiceIndex] - .selected = true; - final isNormalizationError = - igc.spanDataController.isNormalizationError(matchIndex); + match.updatedMatch.match.isNormalizationError(); - final match = igc.igcTextData!.matches[matchIndex].copyWith - ..status = PangeaMatchStatus.accepted; - - igc.igcTextData!.acceptReplacement( - matchIndex, - choiceIndex, + final updatedMatch = igc.igcTextData!.acceptReplacement( + match, + PangeaMatchStatus.accepted, ); _textController.setSystemText( - igc.igcTextData!.originalInput, + igc.igcTextData!.currentText, EditType.igc, ); @@ -429,7 +413,7 @@ class Choreographer { _initChoreoRecord(); choreoRecord!.addRecord( _textController.text, - match: match, + match: updatedMatch, ); } @@ -442,26 +426,24 @@ class Choreographer { s: stack, data: { "igctextData": igc.igcTextData?.toJson(), - "matchIndex": matchIndex, - "choiceIndex": choiceIndex, + "match": match.toJson(), }, ); - igc.igcTextData?.matches.clear(); + igc.clear(); } finally { setState(); } } - void onUndoReplacement(PangeaMatch match) { + void onUndoReplacement(PangeaMatchState match) { try { igc.igcTextData?.undoReplacement(match); - choreoRecord?.choreoSteps.removeWhere( - (step) => step.acceptedOrIgnoredMatch == match, + (step) => step.acceptedOrIgnoredMatch == match.updatedMatch, ); _textController.setSystemText( - igc.igcTextData!.originalInput, + igc.igcTextData!.currentText, EditType.igc, ); } catch (e, s) { @@ -480,52 +462,35 @@ class Choreographer { } void acceptNormalizationMatches() { - final List indices = []; - for (int i = 0; i < igc.igcTextData!.matches.length; i++) { - final isNormalizationError = - igc.spanDataController.isNormalizationError(i); - if (isNormalizationError) indices.add(i); - } - - if (indices.isEmpty) return; + final normalizationsMatches = igc.igcTextData!.openNormalizationMatches; + if (normalizationsMatches.isEmpty) return; _initChoreoRecord(); - final matches = igc.igcTextData!.matches - .where( - (match) => indices.contains(igc.igcTextData!.matches.indexOf(match)), - ) - .toList(); - - for (final match in matches) { - final index = igc.igcTextData!.matches.indexOf(match); - igc.igcTextData!.acceptReplacement( - index, - match.match.choices!.indexWhere( + for (final match in normalizationsMatches) { + match.selectChoice( + match.updatedMatch.match.choices!.indexWhere( (c) => c.isBestCorrection, ), ); - final newMatch = match.copyWith; - newMatch.status = PangeaMatchStatus.automatic; - newMatch.match.length = match.match.choices! - .firstWhere((c) => c.isBestCorrection) - .value - .characters - .length; + final updatedMatch = igc.igcTextData!.acceptReplacement( + match, + PangeaMatchStatus.automatic, + ); _textController.setSystemText( - igc.igcTextData!.originalInput, + igc.igcTextData!.currentText, EditType.igc, ); choreoRecord!.addRecord( currentText, - match: newMatch, + match: updatedMatch, ); } } - void onIgnoreMatch({required int matchIndex}) { + void onIgnoreMatch({required PangeaMatchState match}) { try { if (igc.igcTextData == null) { debugger(when: kDebugMode); @@ -537,33 +502,23 @@ class Choreographer { return; } - if (matchIndex == -1) { - debugger(when: kDebugMode); - throw Exception("Cannot find the ignored match in igcTextData"); - } + final updatedMatch = igc.igcTextData!.ignoreReplacement(match); + igc.onIgnoreMatch(updatedMatch); - igc.onIgnoreMatch(igc.igcTextData!.matches[matchIndex]); - igc.igcTextData!.matches[matchIndex].status = PangeaMatchStatus.ignored; - - final isNormalizationError = - igc.spanDataController.isNormalizationError(matchIndex); - - if (!isNormalizationError) { + if (!updatedMatch.match.isNormalizationError()) { _initChoreoRecord(); choreoRecord!.addRecord( _textController.text, - match: igc.igcTextData!.matches[matchIndex], + match: updatedMatch, ); } - - igc.igcTextData!.matches.removeAt(matchIndex); } catch (err, stack) { debugger(when: kDebugMode); Sentry.addBreadcrumb( Breadcrumb( data: { "igcTextData": igc.igcTextData?.toJson(), - "matchIndex": matchIndex, + "match": match.toJson(), }, ), ); @@ -574,7 +529,7 @@ class Choreographer { "igctextData": igc.igcTextData?.toJson(), }, ); - igc.igcTextData?.matches.clear(); + igc.clear(); } finally { setState(); } @@ -717,7 +672,7 @@ class Choreographer { return AssistanceState.noMessage; } - if ((igc.igcTextData?.matches.isNotEmpty ?? false) || isRunningIT) { + if ((igc.igcTextData?.hasOpenMatches ?? false) || isRunningIT) { return AssistanceState.fetched; } @@ -756,10 +711,8 @@ class Choreographer { } // if they have relevant matches, don't let them send - final hasITMatches = - igc.igcTextData!.matches.any((match) => match.isITStart); - final hasIGCMatches = - igc.igcTextData!.matches.any((match) => !match.isITStart); + final hasITMatches = igc.igcTextData!.hasOpenITMatches; + final hasIGCMatches = igc.igcTextData!.hasOpenIGCMatches; if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) { return false; } diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index 0dd7836ae..84077f73b 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -9,11 +9,13 @@ import 'package:matrix/matrix.dart' hide Result; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/span_data_controller.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'; 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'; @@ -24,11 +26,8 @@ import '../../common/utils/overlay.dart'; class IgcController { Choreographer choreographer; IGCTextData? igcTextData; - late SpanDataController spanDataController; - IgcController(this.choreographer) { - spanDataController = SpanDataController(choreographer); - } + IgcController(this.choreographer); Future getIGCTextData() async { if (choreographer.currentText.isEmpty) return clear(); @@ -68,39 +67,16 @@ class IgcController { if (res.result!.originalInput.trim() != choreographer.currentText.trim()) { return; } - // get ignored matches from the original igcTextData - // if the new matches are the same as the original match - // could possibly change the status of the new match - // thing is the same if the text we are trying to change is the smae - // as the new text we are trying to change (suggestion is the same) - // Check for duplicate or minor text changes that shouldn't trigger suggestions - // checks for duplicate input - - igcTextData = res.result!; - final List filteredMatches = List.from(igcTextData!.matches); - for (final PangeaMatch match in igcTextData!.matches) { - if (IgcRepo.isIgnored(match)) { - filteredMatches.remove(match); - } - } - - igcTextData!.matches = filteredMatches; + final response = res.result!; + igcTextData = IGCTextData( + originalInput: response.originalInput, + matches: response.matches, + ); choreographer.acceptNormalizationMatches(); - // TODO - for each new match, - // check if existing igcTextData has one and only one match with the same error text and correction - // if so, keep the original match and discard the new one - // if not, add the new match to the existing igcTextData - - // After fetching igc data, pre-call span details for each match optimistically. - // This will make the loading of span details faster for the user - if (igcTextData?.matches.isNotEmpty ?? false) { - for (int i = 0; i < igcTextData!.matches.length; i++) { - if (!igcTextData!.matches[i].isITStart) { - spanDataController.getSpanDetails(i); - } - } + for (final match in igcTextData!.openMatches) { + setSpanDetails(match: match); } } @@ -108,8 +84,12 @@ class IgcController { IgcRepo.ignore(match); } + bool get canShowFirstMatch { + return igcTextData?.firstOpenMatch != null; + } + void showFirstMatch(BuildContext context) { - if (igcTextData == null || igcTextData!.matches.isEmpty) { + if (!canShowFirstMatch) { debugger(when: kDebugMode); ErrorHandler.logError( m: "should not be calling showFirstMatch with this igcTextData.", @@ -121,25 +101,20 @@ class IgcController { return; } - const int firstMatchIndex = 0; - final PangeaMatch match = igcTextData!.matches[firstMatchIndex]; - - if (match.isITStart && - // choreographer.itAutoPlayEnabled && - igcTextData != null) { - choreographer.onITStart(igcTextData!.matches[firstMatchIndex]); + final match = igcTextData!.firstOpenMatch!; + if (match.updatedMatch.isITStart && igcTextData != null) { + choreographer.onITStart(match); return; } choreographer.chatController.inputFocus.unfocus(); - MatrixState.pAnyState.closeAllOverlays( - filter: RegExp(r'span_card_overlay_\d+'), - ); + MatrixState.pAnyState.closeAllOverlays(); OverlayUtil.showPositionedCard( - overlayKey: "span_card_overlay_$firstMatchIndex", + overlayKey: + "span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}", context: context, cardToShow: SpanCard( - matchIndex: firstMatchIndex, + match: match, choreographer: choreographer, ), maxHeight: 325, @@ -191,7 +166,7 @@ class IgcController { bool get hasRelevantIGCTextData { if (igcTextData == null) return false; - if (igcTextData!.originalInput != choreographer.currentText) { + if (igcTextData!.currentText != choreographer.currentText) { debugPrint( "returning isIGCTextDataRelevant false because text has changed", ); @@ -202,8 +177,36 @@ class IgcController { clear() { igcTextData = null; - MatrixState.pAnyState.closeAllOverlays( - filter: RegExp(r'span_card_overlay_\d+'), + 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 1b3075fab..68e878709 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -10,12 +10,12 @@ 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/edit_type.dart'; -import 'package:fluffychat/pangea/choreographer/repo/interactive_translation_repo.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'; -import '../repo/custom_input_request_model.dart'; +import '../repo/it_request_model.dart'; import '../repo/it_response_model.dart'; import 'choreographer.dart'; @@ -265,7 +265,7 @@ class ITController { } } - CustomInputRequestModel _request(String textInput) => CustomInputRequestModel( + ITRequestModel _request(String textInput) => ITRequestModel( text: sourceText!, customInput: textInput, sourceLangCode: sourceLangCode, @@ -279,7 +279,10 @@ class ITController { //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); + final itStep = ITStep( + currentITStep!.continuances, + chosen: chosenIndex, + ); completedITSteps.add(itStep); choreographer.onITChoiceSelect(itStep); @@ -393,7 +396,9 @@ class CurrentITStep { .map((e) { //we only want one green choice and for that to be our gold if (e.level == ChoreoConstants.levelThresholdForGreen) { - e.level = ChoreoConstants.levelThresholdForYellow; + return e.copyWith( + level: ChoreoConstants.levelThresholdForYellow, + ); } return e; }), @@ -401,7 +406,7 @@ class CurrentITStep { ]; continuances.shuffle(); } else { - continuances = responseModel.continuances; + continuances = List.from(responseModel.continuances); } } } diff --git a/lib/pangea/choreographer/controllers/span_data_controller.dart b/lib/pangea/choreographer/controllers/span_data_controller.dart deleted file mode 100644 index b6b3070db..000000000 --- a/lib/pangea/choreographer/controllers/span_data_controller.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; -import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; -import 'package:fluffychat/pangea/choreographer/repo/span_data_repo.dart'; -import 'package:fluffychat/pangea/choreographer/utils/text_normalization_util.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; - -class SpanDataController { - late Choreographer choreographer; - SpanDataController(this.choreographer); - - SpanData? _getSpan(int matchIndex) { - if (choreographer.igc.igcTextData == null || - choreographer.igc.igcTextData!.matches.isEmpty || - matchIndex < 0 || - matchIndex >= choreographer.igc.igcTextData!.matches.length) { - debugger(when: kDebugMode); - return null; - } - - /// Retrieves the span data from the `igcTextData` matches at the specified `matchIndex`. - /// Creates a `SpanDetailsRepoReqAndRes` object with the retrieved span data and other parameters. - /// Generates a cache key based on the created `SpanDetailsRepoReqAndRes` object. - return choreographer.igc.igcTextData!.matches[matchIndex].match; - } - - bool isNormalizationError(int matchIndex) { - final span = _getSpan(matchIndex); - if (span == null) return false; - - final correctChoice = span.choices - ?.firstWhereOrNull( - (c) => c.isBestCorrection, - ) - ?.value; - - final errorSpan = span.fullText.substring( - span.offset, - span.offset + span.length, - ); - - return correctChoice != null && - TextNormalizationUtil.normalizeString(correctChoice) == - TextNormalizationUtil.normalizeString(errorSpan); - } - - Future getSpanDetails( - int matchIndex, { - bool force = false, - }) async { - final SpanData? span = _getSpan(matchIndex); - if (span == null || (isNormalizationError(matchIndex) && !force)) return; - final response = await SpanDataRepo.get( - choreographer.accessToken, - request: SpanDetailsRepoReqAndRes( - userL1: choreographer.l1LangCode!, - userL2: choreographer.l2LangCode!, - enableIGC: choreographer.igcEnabled, - enableIT: choreographer.itEnabled, - span: span, - ), - ); - - if (response.result != null) { - choreographer.igc.igcTextData!.matches[matchIndex].match = - response.result!.span; - } - - choreographer.setState(); - } -} diff --git a/lib/pangea/choreographer/enums/pangea_match_status.dart b/lib/pangea/choreographer/enums/pangea_match_status.dart new file mode 100644 index 000000000..ffefe5513 --- /dev/null +++ b/lib/pangea/choreographer/enums/pangea_match_status.dart @@ -0,0 +1,23 @@ +enum PangeaMatchStatus { + open, + ignored, + accepted, + automatic, + unknown; + + static PangeaMatchStatus fromString(String status) { + final String lastPart = status.toString().split('.').last; + switch (lastPart) { + case 'open': + return PangeaMatchStatus.open; + case 'ignored': + return PangeaMatchStatus.ignored; + case 'accepted': + return PangeaMatchStatus.accepted; + case 'automatic': + return PangeaMatchStatus.automatic; + default: + return PangeaMatchStatus.unknown; + } + } +} diff --git a/lib/pangea/choreographer/models/choreo_edit.dart b/lib/pangea/choreographer/models/choreo_edit.dart index 04951e98d..42cd9f44b 100644 --- a/lib/pangea/choreographer/models/choreo_edit.dart +++ b/lib/pangea/choreographer/models/choreo_edit.dart @@ -4,31 +4,37 @@ import 'dart:math'; /// Remove substring of length 'length', starting at position 'offset' /// Then add String 'insert' at that position class ChoreoEdit { - int offset = 0; - int length = 0; - String insert = ""; + final int offset; + final int length; + final String insert; /// Normal constructor created from preexisting ChoreoEdit values - ChoreoEdit({ - required this.offset, - required this.length, - required this.insert, + const ChoreoEdit({ + this.offset = 0, + this.length = 0, + this.insert = "", }); /// Constructor that determines and saves /// edits differentiating originalText and editedText - ChoreoEdit.fromText({ + factory ChoreoEdit.fromText({ required String originalText, required String editedText, }) { if (originalText == editedText) { // No changes, return empty edit - return; + return const ChoreoEdit(); } - offset = _firstDifference(originalText, editedText); - length = _lastDifference(originalText, editedText) + 1 - offset; - insert = _insertion(originalText, editedText); + final offset = _firstDifference(originalText, editedText); + final length = + _lastDifference(originalText, editedText, offset) + 1 - offset; + final insert = _insertion(originalText, editedText, offset, length); + return ChoreoEdit( + offset: offset, + length: length, + insert: insert, + ); } factory ChoreoEdit.fromJson(Map json) { @@ -52,7 +58,7 @@ class ChoreoEdit { } /// Find index of first character where strings differ - int _firstDifference(String originalText, String editedText) { + static int _firstDifference(String originalText, String editedText) { var i = 0; final minLength = min(originalText.length, editedText.length); while (i < minLength && originalText[i] == editedText[i]) { @@ -63,7 +69,11 @@ class ChoreoEdit { /// Starting at the end of both text versions, /// traverse backward until a non-matching char is found - int _lastDifference(String originalText, String editedText) { + static int _lastDifference( + String originalText, + String editedText, + int offset, + ) { var i = originalText.length - 1; var j = editedText.length - 1; while (min(i, j) >= offset && originalText[i] == editedText[j]) { @@ -77,7 +87,12 @@ class ChoreoEdit { /// plus the difference in string length /// If dif is -x and length of deleted text is x, /// inserted text is empty string - String _insertion(String originalText, String editedText) { + static String _insertion( + String originalText, + String editedText, + int offset, + int length, + ) { final insertLength = length + (editedText.length - originalText.length); return editedText.substring(offset, offset + insertLength); } diff --git a/lib/pangea/choreographer/models/choreo_record.dart b/lib/pangea/choreographer/models/choreo_record.dart index 5ba5b0f6d..c9f58fe29 100644 --- a/lib/pangea/choreographer/models/choreo_record.dart +++ b/lib/pangea/choreographer/models/choreo_record.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_edit.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; @@ -16,14 +17,12 @@ class ChoreoRecord { /// ordered versions of the representation, with first being original and last /// being the final sent text /// there is not a 1-to-1 map from steps to matches - List choreoSteps; - - List openMatches; + final List choreoSteps; + final List openMatches; + final String originalText; final Set pastedStrings = {}; - final String originalText; - ChoreoRecord({ required this.choreoSteps, required this.openMatches, @@ -76,7 +75,7 @@ class ChoreoRecord { int length = 0; String insert = ""; - final step = ChoreoRecordStep.fromJson(content); + ChoreoRecordStep step = ChoreoRecordStep.fromJson(content); if (step.acceptedOrIgnoredMatch != null) { final SpanData? match = step.acceptedOrIgnoredMatch?.match; final correction = match?.bestChoice; @@ -109,7 +108,11 @@ class ChoreoRecord { ); currentEdit = textAfter; - step.edits = edits; + step = ChoreoRecordStep( + edits: edits, + acceptedOrIgnoredMatch: step.acceptedOrIgnoredMatch, + itStep: step.itStep, + ); steps.add(step); } } @@ -255,14 +258,14 @@ class ChoreoRecordStep { /// will provide the current step's text /// Should always exist, except when using fromJSON /// on old version of ChoreoRecordStep - ChoreoEdit? edits; + final ChoreoEdit? edits; /// all matches throughout edit process, /// including those open, accepted and ignored /// last step in list may contain open - PangeaMatch? acceptedOrIgnoredMatch; + final PangeaMatch? acceptedOrIgnoredMatch; - ITStep? itStep; + final ITStep? itStep; ChoreoRecordStep({ this.edits, diff --git a/lib/pangea/choreographer/models/igc_text_data_model.dart b/lib/pangea/choreographer/models/igc_text_data_model.dart index e52bad196..f59c31e86 100644 --- a/lib/pangea/choreographer/models/igc_text_data_model.dart +++ b/lib/pangea/choreographer/models/igc_text_data_model.dart @@ -1,269 +1,63 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; +import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; +import 'package:fluffychat/pangea/choreographer/models/igc_text_state.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.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/common/constants/model_keys.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/choreographer/models/pangea_match_state.dart'; +import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; class IGCTextData { - String originalInput; - String? fullTextCorrection; - List matches; - String userL1; - String userL2; - bool enableIT; - bool enableIGC; + final String _originalInput; + final List _matches; + final IGCTextState _state; IGCTextData({ - required this.originalInput, - required this.fullTextCorrection, - required this.matches, - required this.userL1, - required this.userL2, - required this.enableIT, - required this.enableIGC, - }); - - factory IGCTextData.fromJson(Map json) { - return IGCTextData( - matches: json["matches"] != null - ? (json["matches"] as Iterable) - .map( - (e) { - return PangeaMatch.fromJson(e as Map); - }, - ) - .toList() - .cast() - : [], - originalInput: json["original_input"], - fullTextCorrection: json["full_text_correction"], - userL1: json[ModelKey.userL1], - userL2: json[ModelKey.userL2], - enableIT: json["enable_it"], - enableIGC: json["enable_igc"], - ); - } + required String originalInput, + required List matches, + }) : _state = IGCTextState( + currentText: originalInput, + matches: List.from(matches), + ), + _originalInput = originalInput, + _matches = matches; Map toJson() => { - "original_input": originalInput, - "full_text_correction": fullTextCorrection, - "matches": matches.map((e) => e.toJson()).toList(), - ModelKey.userL1: userL1, - ModelKey.userL2: userL2, - "enable_it": enableIT, - "enable_igc": enableIGC, + "original_input": _originalInput, + "matches": _matches.map((e) => e.toJson()).toList(), }; - List _matchIndicesByOffset(int offset) { - final List matchesForOffset = []; - for (final (index, match) in matches.indexed) { - if (match.isOffsetInMatchSpan(offset)) { - matchesForOffset.add(index); - } - } - return matchesForOffset; + bool get hasOpenMatches => _state.hasOpenMatches; + + bool get hasOpenITMatches => _state.hasOpenITMatches; + + bool get hasOpenIGCMatches => _state.hasOpenIGCMatches; + + String get currentText => _state.currentText; + + List get openMatches => _state.openMatches; + + List get closedMatches => _state.closedMatches; + + PangeaMatchState? get firstOpenMatch => _state.firstOpenMatch; + + PangeaMatchState? get openMatch => _state.openMatch; + + PangeaMatchState? getMatchByOffset(int offset) => + _state.getMatchByOffset(offset); + + List get openNormalizationMatches => + _state.openNormalizationMatches; + + void setSpanData(PangeaMatchState match, SpanData spanData) { + _state.setSpanData(match, spanData); } - int getTopMatchIndexForOffset(int offset) => - _matchIndicesByOffset(offset).firstWhereOrNull((matchIndex) { - final match = matches[matchIndex]; - return (enableIT && (match.isITStart || match.needsTranslation)) || - (enableIGC && match.isGrammarMatch); - }) ?? - -1; + PangeaMatch acceptReplacement( + PangeaMatchState match, + PangeaMatchStatus status, + ) => + _state.acceptReplacement(match, status); - int? get _openMatchIndex { - final RegExp pattern = RegExp(r'span_card_overlay_\d+'); - final String? matchingKeys = - MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull; - if (matchingKeys == null) return null; + PangeaMatch ignoreReplacement(PangeaMatchState match) => + _state.ignoreReplacement(match); - final int? index = int.tryParse(matchingKeys.split("_").last); - if (index == null || - matches.length <= index || - matches[index].status != PangeaMatchStatus.open) { - return null; - } - - return index; - } - - InlineSpan _matchSpan( - PangeaMatch match, - TextStyle style, - void Function(PangeaMatch)? onUndo, - ) { - if (match.status == PangeaMatchStatus.automatic) { - final span = originalInput.characters - .getRange( - match.match.offset, - match.match.offset + match.match.length, - ) - .toString(); - - final originalText = match.match.fullText.characters - .getRange( - match.match.offset, - match.match.offset + match.match.length, - ) - .toString(); - - return AutocorrectSpan( - transformTargetId: - "autocorrection_${match.match.offset}_${match.match.length}", - currentText: span, - originalText: originalText, - onUndo: () => onUndo?.call(match), - style: style, - ); - } else { - return TextSpan( - text: originalInput.characters - .getRange( - match.match.offset, - match.match.offset + match.match.length, - ) - .toString(), - style: style, - ); - } - } - - /// 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({ - required List choreoSteps, - void Function(PangeaMatch)? onUndo, - TextStyle? defaultStyle, - }) { - final automaticMatches = choreoSteps - .map((s) => s.acceptedOrIgnoredMatch) - .whereType() - .where((m) => m.status == PangeaMatchStatus.automatic) - .toList(); - - final textSpanMatches = [...matches, ...automaticMatches] - ..sort((a, b) => a.match.offset.compareTo(b.match.offset)); - - final spans = []; - int cursor = 0; - - for (final match in textSpanMatches) { - if (cursor < match.match.offset) { - final text = originalInput.characters - .getRange(cursor, match.match.offset) - .toString(); - spans.add(TextSpan(text: text, style: defaultStyle)); - } - - final matchIndex = matches.indexWhere( - (m) => m.match.offset == match.match.offset, - ); - - final style = MatchStyleUtil.textStyle( - match, - defaultStyle, - _openMatchIndex != null && _openMatchIndex == matchIndex, - ); - - spans.add(_matchSpan(match, style, onUndo)); - cursor = match.match.offset + match.match.length; - } - - if (cursor < originalInput.characters.length) { - spans.add( - TextSpan( - text: originalInput.characters - .getRange(cursor, originalInput.characters.length) - .toString(), - style: defaultStyle, - ), - ); - } - - return spans; - } - - void acceptReplacement( - int matchIndex, - int choiceIndex, - ) { - final PangeaMatch pangeaMatch = matches.removeAt(matchIndex); - if (pangeaMatch.match.choices == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "pangeaMatch.match.choices is null in acceptReplacement", - data: { - "match": pangeaMatch.match.toJson(), - }, - ); - return; - } - - _runReplacement( - pangeaMatch.match.offset, - pangeaMatch.match.length, - pangeaMatch.match.choices![choiceIndex].value, - ); - } - - void undoReplacement(PangeaMatch match) { - final choice = match.match.choices - ?.firstWhereOrNull( - (c) => c.isBestCorrection, - ) - ?.value; - - if (choice == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "pangeaMatch.match.choices has no best correction in undoReplacement", - data: { - "match": match.match.toJson(), - }, - ); - return; - } - - final String replacement = match.match.fullText.characters - .getRange( - match.match.offset, - match.match.offset + match.match.length, - ) - .toString(); - - _runReplacement( - match.match.offset, - choice.characters.length, - replacement, - ); - } - - /// Internal runner for applying a replacement to the current text. - void _runReplacement( - int offset, - int length, - String replacement, - ) { - final newStart = originalInput.characters.take(offset); - final newEnd = originalInput.characters.skip(offset + length); - final fullText = newStart + replacement.characters + newEnd; - originalInput = fullText.toString(); - - for (final match in matches) { - match.match.fullText = originalInput; - if (match.match.offset > offset) { - match.match.offset += replacement.characters.length - length; - } - } - } + void undoReplacement(PangeaMatchState match) => _state.undoReplacement(match); } diff --git a/lib/pangea/choreographer/models/igc_text_state.dart b/lib/pangea/choreographer/models/igc_text_state.dart new file mode 100644 index 000000000..b649e90e3 --- /dev/null +++ b/lib/pangea/choreographer/models/igc_text_state.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.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/models/span_data.dart'; +import 'package:fluffychat/pangea/choreographer/repo/igc_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class IGCTextState { + String _currentText; + final List _openMatches = []; + final List _closedMatches = []; + + IGCTextState({ + required String currentText, + required List matches, + }) : _currentText = currentText { + _openMatches.addAll( + matches + .where((match) => match.status == PangeaMatchStatus.open) + .map((match) { + return PangeaMatchState( + match: match.match, + status: match.status, + original: match, + ); + }), + ); + + _closedMatches.addAll( + matches + .where((match) => match.status != PangeaMatchStatus.open) + .map((match) { + return PangeaMatchState( + match: match.match, + status: match.status, + original: match, + ); + }), + ); + + _filterIgnoredMatches(); + } + + String get currentText => _currentText; + + List get openMatches => _openMatches; + + List get closedMatches => _closedMatches; + + List get openNormalizationMatches => _openMatches + .where((match) => match.updatedMatch.match.isNormalizationError()) + .toList(); + + bool get hasOpenMatches => _openMatches.isNotEmpty; + + bool get hasOpenITMatches => + _openMatches.any((match) => match.updatedMatch.isITStart); + + bool get hasOpenIGCMatches => + _openMatches.any((match) => !match.updatedMatch.isITStart); + + PangeaMatchState? get firstOpenMatch => _openMatches.firstOrNull; + + PangeaMatchState? getMatchByOffset(int offset) => + _openMatches.firstWhereOrNull( + (match) => match.updatedMatch.match.isOffsetInMatchSpan(offset), + ); + + PangeaMatchState? get openMatch { + final RegExp pattern = RegExp(r'span_card_overlay_.+'); + final String? matchingKeys = + MatrixState.pAnyState.getMatchingOverlayKeys(pattern).firstOrNull; + if (matchingKeys == null) return null; + + final parts = matchingKeys.split("_"); + if (parts.length != 5) return null; + final offset = int.tryParse(parts[3]); + final length = int.tryParse(parts[4]); + if (offset == null || length == null) return null; + return _openMatches.firstWhereOrNull( + (match) => + match.updatedMatch.match.offset == offset && + match.updatedMatch.match.length == length, + ); + } + + void _filterIgnoredMatches() { + for (final match in _openMatches) { + if (IgcRepo.isIgnored(match.updatedMatch)) { + ignoreReplacement(match); + } + } + } + + void setSpanData(PangeaMatchState match, SpanData spanData) { + final openMatch = _openMatches.firstWhereOrNull( + (m) => m.originalMatch == match.originalMatch, + ); + + match.setMatch(spanData); + _openMatches.remove(openMatch); + _openMatches.add(match); + } + + PangeaMatch acceptReplacement( + PangeaMatchState match, + PangeaMatchStatus status, + ) { + final openMatch = _openMatches.firstWhereOrNull( + (m) => m.originalMatch == match.originalMatch, + ); + + if (match.updatedMatch.match.selectedChoice == null) { + throw "match.match.selectedChoice is null in acceptReplacement"; + } + + match.setStatus(status); + _openMatches.remove(openMatch); + _closedMatches.add(match); + + _runReplacement( + match.updatedMatch.match.offset, + match.updatedMatch.match.length, + match.updatedMatch.match.selectedChoice!.value, + ); + + return match.updatedMatch; + } + + PangeaMatch ignoreReplacement(PangeaMatchState match) { + final openMatch = _openMatches.firstWhereOrNull( + (m) => m.originalMatch == match.originalMatch, + ); + + match.setStatus(PangeaMatchStatus.ignored); + _openMatches.remove(openMatch); + _closedMatches.add(match); + return match.updatedMatch; + } + + void undoReplacement(PangeaMatchState match) { + final closedMatch = _closedMatches.firstWhereOrNull( + (m) => m.originalMatch == match.originalMatch, + ); + + _closedMatches.remove(closedMatch); + + final choice = match.updatedMatch.match.selectedChoice?.value; + + if (choice == null) { + throw "match.match.selectedChoice is null in undoReplacement"; + } + + final String replacement = match.originalMatch.match.fullText.characters + .getRange( + match.originalMatch.match.offset, + match.originalMatch.match.offset + match.originalMatch.match.length, + ) + .toString(); + + _runReplacement( + match.originalMatch.match.offset, + choice.characters.length, + replacement, + ); + } + + void _runReplacement( + int offset, + int length, + String replacement, + ) { + final start = _currentText.characters.take(offset); + final end = _currentText.characters.skip(offset + length); + final fullText = start + replacement.characters + end; + _currentText = fullText.toString(); + + for (int i = 0; i < _openMatches.length; i++) { + final match = _openMatches[i].updatedMatch.match; + final updatedMatch = match.copyWith( + fullText: _currentText, + offset: match.offset > offset + ? match.offset + replacement.characters.length - length + : match.offset, + ); + _openMatches[i].setMatch(updatedMatch); + } + + for (int i = 0; i < _closedMatches.length; i++) { + final match = _closedMatches[i].updatedMatch.match; + final updatedMatch = match.copyWith( + fullText: _currentText, + offset: match.offset > offset + ? match.offset + replacement.characters.length - length + : match.offset, + ); + _closedMatches[i].setMatch(updatedMatch); + } + } +} diff --git a/lib/pangea/choreographer/models/it_step.dart b/lib/pangea/choreographer/models/it_step.dart index 5e3ab7ede..cf0cbbd0f 100644 --- a/lib/pangea/choreographer/models/it_step.dart +++ b/lib/pangea/choreographer/models/it_step.dart @@ -4,10 +4,10 @@ import 'package:fluffychat/l10n/l10n.dart'; import '../constants/choreo_constants.dart'; class ITStep { - List continuances; - int? chosen; - String? customInput; - bool showAlternativeTranslationOption = false; + final List continuances; + final int? chosen; + final String? customInput; + final bool showAlternativeTranslationOption = false; ITStep( this.continuances, { @@ -70,21 +70,18 @@ class ITStep { } class Continuance { - /// only saving this top set in a condensed json form - double probability; - int level; - String text; - // List tokens; + final double probability; + final int level; + final String text; - /// saving this in a full json form - String description; - int? indexSavedByServer; - bool wasClicked; - bool inDictionary; - bool hasInfo; - bool gold; + final String description; + final int? indexSavedByServer; + final bool wasClicked; + final bool inDictionary; + final bool hasInfo; + final bool gold; - Continuance({ + const Continuance({ required this.probability, required this.level, required this.text, @@ -94,18 +91,9 @@ class Continuance { required this.inDictionary, required this.hasInfo, required this.gold, - // required this.tokens, }); factory Continuance.fromJson(Map json) { - // final List tokensInternal = (json[ModelKey.tokens] != null) - // ? (json[ModelKey.tokens] as Iterable) - // .map( - // (e) => PangeaToken.fromJson(e as Map), - // ) - // .toList() - // .cast() - // : []; return Continuance( probability: json['probability'].toDouble(), level: json['level'], @@ -116,7 +104,6 @@ class Continuance { wasClicked: json['clkd'] ?? false, hasInfo: json['has_info'] ?? false, gold: json['gold'] ?? false, - // tokens: tokensInternal, ); } @@ -138,6 +125,30 @@ class Continuance { return data; } + Continuance copyWith({ + double? probability, + int? level, + String? text, + String? description, + int? indexSavedByServer, + bool? wasClicked, + bool? inDictionary, + bool? hasInfo, + bool? gold, + }) { + return Continuance( + probability: probability ?? this.probability, + level: level ?? this.level, + text: text ?? this.text, + description: description ?? this.description, + indexSavedByServer: indexSavedByServer ?? this.indexSavedByServer, + wasClicked: wasClicked ?? this.wasClicked, + inDictionary: inDictionary ?? this.inDictionary, + hasInfo: hasInfo ?? this.hasInfo, + gold: gold ?? this.gold, + ); + } + Color? get color { if (!wasClicked) return null; switch (level) { diff --git a/lib/pangea/choreographer/models/language_detection_model.dart b/lib/pangea/choreographer/models/language_detection_model.dart index aaac2decd..533a1b547 100644 --- a/lib/pangea/choreographer/models/language_detection_model.dart +++ b/lib/pangea/choreographer/models/language_detection_model.dart @@ -1,10 +1,10 @@ import 'package:fluffychat/pangea/common/constants/model_keys.dart'; class LanguageDetection { - String langCode; - double confidence; + final String langCode; + final double confidence; - LanguageDetection({ + const LanguageDetection({ required this.langCode, required this.confidence, }); diff --git a/lib/pangea/choreographer/models/pangea_match_model.dart b/lib/pangea/choreographer/models/pangea_match_model.dart index 8702ffda3..8bdcea049 100644 --- a/lib/pangea/choreographer/models/pangea_match_model.dart +++ b/lib/pangea/choreographer/models/pangea_match_model.dart @@ -2,40 +2,17 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.dart'; import 'package:fluffychat/pangea/choreographer/enums/span_data_type.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import '../constants/match_rule_ids.dart'; import 'span_data.dart'; -enum PangeaMatchStatus { - open, - ignored, - accepted, - automatic, - unknown; - - static PangeaMatchStatus fromString(String status) { - final String lastPart = status.toString().split('.').last; - switch (lastPart) { - case 'open': - return PangeaMatchStatus.open; - case 'ignored': - return PangeaMatchStatus.ignored; - case 'accepted': - return PangeaMatchStatus.accepted; - case 'automatic': - return PangeaMatchStatus.automatic; - default: - return PangeaMatchStatus.unknown; - } - } -} - class PangeaMatch { - SpanData match; - PangeaMatchStatus status; + final SpanData match; + final PangeaMatchStatus status; - PangeaMatch({ + const PangeaMatch({ required this.match, required this.status, }); @@ -96,8 +73,15 @@ class PangeaMatch { return match.fullText.substring(beginning, end); } - bool isOffsetInMatchSpan(int offset) => - offset >= match.offset && offset < match.offset + match.length; + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! PangeaMatch) return false; + return other.match == match && other.status == status; + } - PangeaMatch get copyWith => PangeaMatch.fromJson(toJson()); + @override + int get hashCode { + return match.hashCode ^ status.hashCode; + } } diff --git a/lib/pangea/choreographer/models/pangea_match_state.dart b/lib/pangea/choreographer/models/pangea_match_state.dart new file mode 100644 index 000000000..c3f2ab657 --- /dev/null +++ b/lib/pangea/choreographer/models/pangea_match_state.dart @@ -0,0 +1,49 @@ +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/span_data.dart'; + +class PangeaMatchState { + final PangeaMatch _original; + SpanData _match; + PangeaMatchStatus _status; + + PangeaMatchState({ + required PangeaMatch original, + required SpanData match, + required PangeaMatchStatus status, + }) : _original = original, + _match = match, + _status = status; + + PangeaMatch get originalMatch => _original; + + PangeaMatch get updatedMatch => PangeaMatch( + match: _match, + status: _status, + ); + + void setStatus(PangeaMatchStatus status) { + _status = status; + } + + void setMatch(SpanData match) { + _match = match; + } + + void selectChoice(int index) { + final choices = List.from(_match.choices ?? []); + choices[index] = choices[index].copyWith( + selected: true, + timestamp: DateTime.now(), + ); + setMatch(_match.copyWith(choices: choices)); + } + + Map toJson() { + return { + 'originalMatch': _original.toJson(), + 'match': _match.toJson(), + 'status': _status.toString(), + }; + } +} diff --git a/lib/pangea/choreographer/models/span_data.dart b/lib/pangea/choreographer/models/span_data.dart index fb26ff28d..860f00911 100644 --- a/lib/pangea/choreographer/models/span_data.dart +++ b/lib/pangea/choreographer/models/span_data.dart @@ -8,10 +8,20 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/choreographer/utils/text_normalization_util.dart'; import '../enums/span_choice_type.dart'; import '../enums/span_data_type.dart'; class SpanData { + final String? message; + final String? shortMessage; + final List? choices; + final int offset; + final int length; + final String fullText; + final SpanDataType type; + final Rule? rule; + SpanData({ required this.message, required this.shortMessage, @@ -23,6 +33,28 @@ class SpanData { required this.rule, }); + SpanData copyWith({ + String? message, + String? shortMessage, + List? choices, + int? offset, + int? length, + String? fullText, + SpanDataType? type, + Rule? rule, + }) { + return SpanData( + message: message ?? this.message, + shortMessage: shortMessage ?? this.shortMessage, + choices: choices ?? this.choices, + offset: offset ?? this.offset, + length: length ?? this.length, + fullText: fullText ?? this.fullText, + type: type ?? this.type, + rule: rule ?? this.rule, + ); + } + factory SpanData.fromJson(Map json) { final Iterable? choices = json['choices'] ?? json['replacements']; return SpanData( @@ -44,15 +76,6 @@ class SpanData { ); } - String? message; - String? shortMessage; - List? choices; - int offset; - int length; - String fullText; - SpanDataType type; - Rule? rule; - Map toJson() => { 'message': message, 'short_message': shortMessage, @@ -66,20 +89,107 @@ class SpanData { 'rule': rule?.toJson(), }; + bool isOffsetInMatchSpan(int offset) => + offset >= this.offset && offset < this.offset + length; + SpanChoice? get bestChoice { return choices?.firstWhereOrNull( (choice) => choice.isBestCorrection, ); } + + int get selectedChoiceIndex { + if (choices == null) { + return -1; + } + + // if user ever selected the correct choice, automatically select it + final selectedCorrectIndex = choices!.indexWhere((choice) { + return choice.selected && choice.isBestCorrection; + }); + + if (selectedCorrectIndex != -1) { + return selectedCorrectIndex; + } + + SpanChoice? mostRecent; + for (int i = 0; i < choices!.length; i++) { + final choice = choices![i]; + if (choice.timestamp != null && + (mostRecent == null || + choice.timestamp!.isAfter(mostRecent.timestamp!))) { + mostRecent = choice; + } + } + return mostRecent != null ? choices!.indexOf(mostRecent) : -1; + } + + SpanChoice? get selectedChoice { + final index = selectedChoiceIndex; + if (index == -1) { + return null; + } + return choices![index]; + } + + bool isNormalizationError() { + final correctChoice = choices + ?.firstWhereOrNull( + (c) => c.isBestCorrection, + ) + ?.value; + + final errorSpan = fullText.characters.skip(offset).take(length).toString(); + + return correctChoice != null && + TextNormalizationUtil.normalizeString(correctChoice) == + TextNormalizationUtil.normalizeString(errorSpan); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! SpanData) return false; + if (other.message != message) return false; + if (other.shortMessage != shortMessage) return false; + if (other.offset != offset) return false; + if (other.length != length) return false; + if (other.fullText != fullText) return false; + if (other.type != type) return false; + if (other.rule != rule) return false; + if (const ListEquality().equals( + other.choices?.sorted((a, b) => b.value.compareTo(a.value)), + choices?.sorted((a, b) => b.value.compareTo(a.value)), + ) == + false) { + return false; + } + return true; + } + + @override + int get hashCode { + return message.hashCode ^ + shortMessage.hashCode ^ + Object.hashAll( + (choices ?? []) + .sorted((a, b) => b.value.compareTo(a.value)) + .map((choice) => choice.hashCode), + ) ^ + offset.hashCode ^ + length.hashCode ^ + fullText.hashCode ^ + type.hashCode ^ + rule.hashCode; + } } class SpanChoice { - String value; - SpanChoiceType type; - bool selected; - String? feedback; - DateTime? timestamp; - // List tokens; + final String value; + final SpanChoiceType type; + final bool selected; + final String? feedback; + final DateTime? timestamp; SpanChoice({ required this.value, @@ -87,18 +197,25 @@ class SpanChoice { this.feedback, this.selected = false, this.timestamp, - // this.tokens = const [], }); + SpanChoice copyWith({ + String? value, + SpanChoiceType? type, + String? feedback, + bool? selected, + DateTime? timestamp, + }) { + return SpanChoice( + value: value ?? this.value, + type: type ?? this.type, + feedback: feedback ?? this.feedback, + selected: selected ?? this.selected, + timestamp: timestamp ?? this.timestamp, + ); + } + factory SpanChoice.fromJson(Map json) { - // final List tokensInternal = (json[ModelKey.tokens] != null) - // ? (json[ModelKey.tokens] as Iterable) - // .map( - // (e) => PangeaToken.fromJson(e as Map), - // ) - // .toList() - // .cast() - // : []; return SpanChoice( value: json['value'] as String, type: json['type'] != null @@ -111,7 +228,6 @@ class SpanChoice { selected: json['selected'] ?? false, timestamp: json['timestamp'] != null ? DateTime.parse(json['timestamp']) : null, - // tokens: tokensInternal, ); } @@ -121,7 +237,6 @@ class SpanChoice { 'selected': selected, 'feedback': feedback, 'timestamp': timestamp?.toIso8601String(), - // 'tokens': tokens.map((e) => e.toJson()).toList(), }; String feedbackToDisplay(BuildContext context) { @@ -147,38 +262,51 @@ class SpanChoice { other.type.toString() == type.toString() && other.selected == selected && other.feedback == feedback && - other.timestamp?.toIso8601String() == timestamp?.toIso8601String(); + other.timestamp == timestamp; } @override int get hashCode { - return Object.hashAll([ - value.hashCode, - type.toString().hashCode, - selected.hashCode, - feedback.hashCode, - timestamp?.toIso8601String().hashCode, - ]); + return value.hashCode ^ + type.hashCode ^ + selected.hashCode ^ + feedback.hashCode ^ + timestamp.hashCode; } } class Rule { - Rule({ + final String id; + + const Rule({ required this.id, }); + factory Rule.fromJson(Map json) => Rule( id: json['id'] as String, ); - String id; - Map toJson() => { 'id': id, }; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Rule) return false; + return other.id == id; + } + + @override + int get hashCode { + return id.hashCode; + } } class SpanDataType { - SpanDataType({ + final SpanDataTypeEnum typeName; + + const SpanDataType({ required this.typeName, }); @@ -193,9 +321,20 @@ class SpanDataType { : SpanDataTypeEnum.correction, ); } - SpanDataTypeEnum typeName; Map toJson() => { 'type_name': typeName.name, }; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! SpanDataType) return false; + return other.typeName == typeName; + } + + @override + int get hashCode { + return typeName.hashCode; + } } diff --git a/lib/pangea/choreographer/repo/igc_repo.dart b/lib/pangea/choreographer/repo/igc_repo.dart index 862505ca9..cdcff5585 100644 --- a/lib/pangea/choreographer/repo/igc_repo.dart +++ b/lib/pangea/choreographer/repo/igc_repo.dart @@ -7,14 +7,14 @@ import 'package:http/http.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/igc_request_model.dart'; +import 'package:fluffychat/pangea/choreographer/repo/igc_response_model.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; -import '../models/igc_text_data_model.dart'; class _IgcCacheItem { - final Future data; + final Future data; final DateTime timestamp; const _IgcCacheItem({ @@ -52,7 +52,7 @@ class IgcRepo { static final Map _ignoredMatchCache = {}; static const Duration _cacheDuration = Duration(minutes: 10); - static Future> get( + static Future> get( String? accessToken, IGCRequestModel igcRequest, ) { @@ -69,7 +69,7 @@ class IgcRepo { return _getResult(igcRequest, future); } - static Future _fetch( + static Future _fetch( String? accessToken, { required IGCRequestModel igcRequest, }) async { @@ -91,12 +91,12 @@ class IgcRepo { final Map json = jsonDecode(utf8.decode(res.bodyBytes).toString()); - return IGCTextData.fromJson(json); + return IGCResponseModel.fromJson(json); } - static Future> _getResult( + static Future> _getResult( IGCRequestModel request, - Future future, + Future future, ) async { try { final res = await future; @@ -112,7 +112,7 @@ class IgcRepo { } } - static Future? _getCached( + static Future? _getCached( IGCRequestModel request, ) { final cacheKeys = [..._igcCache.keys]; @@ -129,7 +129,7 @@ class IgcRepo { static void _setCached( IGCRequestModel request, - Future response, + Future response, ) => _igcCache[request.hashCode.toString()] = _IgcCacheItem( data: response, diff --git a/lib/pangea/choreographer/repo/igc_response_model.dart b/lib/pangea/choreographer/repo/igc_response_model.dart new file mode 100644 index 000000000..a576539ad --- /dev/null +++ b/lib/pangea/choreographer/repo/igc_response_model.dart @@ -0,0 +1,53 @@ +import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; + +class IGCResponseModel { + final String originalInput; + final String? fullTextCorrection; + final List matches; + final String userL1; + final String userL2; + final bool enableIT; + final bool enableIGC; + + IGCResponseModel({ + required this.originalInput, + required this.fullTextCorrection, + required this.matches, + required this.userL1, + required this.userL2, + required this.enableIT, + required this.enableIGC, + }); + + factory IGCResponseModel.fromJson(Map json) { + return IGCResponseModel( + matches: json["matches"] != null + ? (json["matches"] as Iterable) + .map( + (e) { + return PangeaMatch.fromJson(e as Map); + }, + ) + .toList() + .cast() + : [], + originalInput: json["original_input"], + fullTextCorrection: json["full_text_correction"], + userL1: json[ModelKey.userL1], + userL2: json[ModelKey.userL2], + enableIT: json["enable_it"], + enableIGC: json["enable_igc"], + ); + } + + Map toJson() => { + "original_input": originalInput, + "full_text_correction": fullTextCorrection, + "matches": matches.map((e) => e.toJson()).toList(), + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + "enable_it": enableIT, + "enable_igc": enableIGC, + }; +} diff --git a/lib/pangea/choreographer/repo/interactive_translation_repo.dart b/lib/pangea/choreographer/repo/it_repo.dart similarity index 91% rename from lib/pangea/choreographer/repo/interactive_translation_repo.dart rename to lib/pangea/choreographer/repo/it_repo.dart index 87a815deb..7c36b33dc 100644 --- a/lib/pangea/choreographer/repo/interactive_translation_repo.dart +++ b/lib/pangea/choreographer/repo/it_repo.dart @@ -8,7 +8,7 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; -import 'custom_input_request_model.dart'; +import 'it_request_model.dart'; import 'it_response_model.dart'; class _ITCacheItem { @@ -26,7 +26,7 @@ class ITRepo { static const Duration _cacheDuration = Duration(minutes: 10); static Future> get( - CustomInputRequestModel request, + ITRequestModel request, ) { final cached = _getCached(request); if (cached != null) { @@ -39,7 +39,7 @@ class ITRepo { } static Future _fetch( - CustomInputRequestModel request, + ITRequestModel request, ) async { final Requests req = Requests( choreoApiKey: Environment.choreoApiKey, @@ -57,7 +57,7 @@ class ITRepo { } static Future> _getResult( - CustomInputRequestModel request, + ITRequestModel request, Future future, ) async { try { @@ -75,7 +75,7 @@ class ITRepo { } static Future? _getCached( - CustomInputRequestModel request, + ITRequestModel request, ) { final cacheKeys = [..._cache.keys]; for (final key in cacheKeys) { @@ -87,7 +87,7 @@ class ITRepo { } static void _setCached( - CustomInputRequestModel request, + ITRequestModel request, Future response, ) { _cache[request.hashCode.toString()] = _ITCacheItem( diff --git a/lib/pangea/choreographer/repo/custom_input_request_model.dart b/lib/pangea/choreographer/repo/it_request_model.dart similarity index 90% rename from lib/pangea/choreographer/repo/custom_input_request_model.dart rename to lib/pangea/choreographer/repo/it_request_model.dart index 2ef92400e..49a747ed0 100644 --- a/lib/pangea/choreographer/repo/custom_input_request_model.dart +++ b/lib/pangea/choreographer/repo/it_request_model.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; -class CustomInputRequestModel { +class ITRequestModel { final String text; final String customInput; final String sourceLangCode; @@ -14,7 +14,7 @@ class CustomInputRequestModel { final String? goldTranslation; final List? goldContinuances; - const CustomInputRequestModel({ + const ITRequestModel({ required this.text, required this.customInput, required this.sourceLangCode, @@ -25,7 +25,7 @@ class CustomInputRequestModel { required this.goldContinuances, }); - factory CustomInputRequestModel.fromJson(json) => CustomInputRequestModel( + factory ITRequestModel.fromJson(json) => ITRequestModel( text: json['text'], customInput: json['custom_input'], sourceLangCode: json[ModelKey.srcLang], @@ -34,7 +34,7 @@ class CustomInputRequestModel { roomId: json['room_id'], goldTranslation: json['gold_translation'], goldContinuances: json['gold_continuances'] != null - ? List.from(json['gold_continuances']) + ? (json['gold_continuances']) .map((e) => Continuance.fromJson(e)) .toList() : null, @@ -57,7 +57,7 @@ class CustomInputRequestModel { bool operator ==(Object other) { if (identical(this, other)) return true; - return other is CustomInputRequestModel && + return other is ITRequestModel && other.text == text && other.customInput == customInput && other.sourceLangCode == sourceLangCode && diff --git a/lib/pangea/choreographer/repo/span_data_repo.dart b/lib/pangea/choreographer/repo/span_data_repo.dart index 63322d503..4466d62a2 100644 --- a/lib/pangea/choreographer/repo/span_data_repo.dart +++ b/lib/pangea/choreographer/repo/span_data_repo.dart @@ -1,18 +1,17 @@ import 'dart:convert'; import 'package:async/async.dart'; -import 'package:collection/collection.dart'; import 'package:http/http.dart'; -import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; +import 'package:fluffychat/pangea/choreographer/repo/span_data_request.dart'; +import 'package:fluffychat/pangea/choreographer/repo/span_data_response.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import '../../common/constants/model_keys.dart'; import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; class _SpanDetailsCacheItem { - final Future data; + final Future data; final DateTime timestamp; const _SpanDetailsCacheItem({ @@ -25,9 +24,9 @@ class SpanDataRepo { static final Map _cache = {}; static const Duration _cacheDuration = Duration(minutes: 10); - static Future> get( + static Future> get( String? accessToken, { - required SpanDetailsRepoReqAndRes request, + required SpanDetailsRequest request, }) async { final cached = _getCached(request); if (cached != null) { @@ -42,28 +41,32 @@ class SpanDataRepo { return _getResult(request, future); } - static Future _fetch( + static Future _fetch( String? accessToken, { - required SpanDetailsRepoReqAndRes request, + required SpanDetailsRequest request, }) async { final Requests req = Requests( accessToken: accessToken, choreoApiKey: Environment.choreoApiKey, ); + final Response res = await req.post( url: PApiUrls.spanDetails, body: request.toJson(), ); - final Map json = - jsonDecode(utf8.decode(res.bodyBytes).toString()); + if (res.statusCode != 200) { + throw Exception('Failed to load span details'); + } - return SpanDetailsRepoReqAndRes.fromJson(json); + return SpanDetailsResponse.fromJson( + jsonDecode(utf8.decode(res.bodyBytes)), + ); } - static Future> _getResult( - SpanDetailsRepoReqAndRes request, - Future future, + static Future> _getResult( + SpanDetailsRequest request, + Future future, ) async { try { final res = await future; @@ -79,8 +82,8 @@ class SpanDataRepo { } } - static Future? _getCached( - SpanDetailsRepoReqAndRes request, + static Future? _getCached( + SpanDetailsRequest request, ) { final cacheKeys = [..._cache.keys]; for (final key in cacheKeys) { @@ -92,8 +95,8 @@ class SpanDataRepo { } static void _setCached( - SpanDetailsRepoReqAndRes request, - Future response, + SpanDetailsRequest request, + Future response, ) { _cache[request.hashCode.toString()] = _SpanDetailsCacheItem( data: response, @@ -101,75 +104,3 @@ class SpanDataRepo { ); } } - -class SpanDetailsRepoReqAndRes { - String userL1; - String userL2; - bool enableIT; - bool enableIGC; - SpanData span; - - SpanDetailsRepoReqAndRes({ - required this.userL1, - required this.userL2, - required this.enableIGC, - required this.enableIT, - required this.span, - }); - - Map toJson() => { - ModelKey.userL1: userL1, - ModelKey.userL2: userL2, - "enable_it": enableIT, - "enable_igc": enableIGC, - 'span': span.toJson(), - }; - - factory SpanDetailsRepoReqAndRes.fromJson(Map json) => - SpanDetailsRepoReqAndRes( - userL1: json['user_l1'] as String, - userL2: json['user_l2'] as String, - enableIT: json['enable_it'] as bool, - enableIGC: json['enable_igc'] as bool, - span: SpanData.fromJson(json['span']), - ); - - /// Overrides the equality operator to compare two [SpanDetailsRepoReqAndRes] objects. - /// Returns true if the objects are identical or have the same property - /// values (based on the results of the toJson function), false otherwise. - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! SpanDetailsRepoReqAndRes) return false; - if (other.userL1 != userL1) return false; - if (other.userL2 != userL2) return false; - if (other.enableIT != enableIT) return false; - if (other.enableIGC != enableIGC) return false; - if (const ListEquality().equals( - other.span.choices?.sorted((a, b) => b.value.compareTo(a.value)), - span.choices?.sorted((a, b) => b.value.compareTo(a.value)), - ) == - false) { - return false; - } - return true; - } - - /// Overrides the hashCode getter to generate a hash code for the [SpanDetailsRepoReqAndRes] object. - /// Used as keys in response cache in igc_controller. - @override - int get hashCode { - return Object.hashAll([ - userL1.hashCode, - userL2.hashCode, - enableIT.hashCode, - enableIGC.hashCode, - if (span.choices != null) - Object.hashAll( - span.choices! - .sorted((a, b) => b.value.compareTo(a.value)) - .map((choice) => choice.hashCode), - ), - ]); - } -} diff --git a/lib/pangea/choreographer/repo/span_data_request.dart b/lib/pangea/choreographer/repo/span_data_request.dart new file mode 100644 index 000000000..9a85deb7f --- /dev/null +++ b/lib/pangea/choreographer/repo/span_data_request.dart @@ -0,0 +1,47 @@ +import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; + +class SpanDetailsRequest { + final String userL1; + final String userL2; + final bool enableIT; + final bool enableIGC; + final SpanData span; + + const SpanDetailsRequest({ + required this.userL1, + required this.userL2, + required this.enableIGC, + required this.enableIT, + required this.span, + }); + + Map toJson() => { + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + "enable_it": enableIT, + "enable_igc": enableIGC, + 'span': span.toJson(), + }; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! SpanDetailsRequest) return false; + if (other.userL1 != userL1) return false; + if (other.userL2 != userL2) return false; + if (other.enableIT != enableIT) return false; + if (other.enableIGC != enableIGC) return false; + if (other.span != span) return false; + return true; + } + + @override + int get hashCode { + return userL1.hashCode ^ + userL2.hashCode ^ + enableIT.hashCode ^ + enableIGC.hashCode ^ + span.hashCode; + } +} diff --git a/lib/pangea/choreographer/repo/span_data_response.dart b/lib/pangea/choreographer/repo/span_data_response.dart new file mode 100644 index 000000000..e203c9477 --- /dev/null +++ b/lib/pangea/choreographer/repo/span_data_response.dart @@ -0,0 +1,26 @@ +import 'package:fluffychat/pangea/choreographer/models/span_data.dart'; + +class SpanDetailsResponse { + final String userL1; + final String userL2; + final bool enableIT; + final bool enableIGC; + final SpanData span; + + const SpanDetailsResponse({ + required this.userL1, + required this.userL2, + required this.enableIGC, + required this.enableIT, + required this.span, + }); + + factory SpanDetailsResponse.fromJson(Map json) => + SpanDetailsResponse( + userL1: json['user_l1'] as String, + userL2: json['user_l2'] as String, + enableIT: json['enable_it'] as bool, + enableIGC: json['enable_igc'] as bool, + span: SpanData.fromJson(json['span']), + ); +} diff --git a/lib/pangea/choreographer/utils/match_style_util.dart b/lib/pangea/choreographer/utils/match_style_util.dart index 2dd6c0a71..072240601 100644 --- a/lib/pangea/choreographer/utils/match_style_util.dart +++ b/lib/pangea/choreographer/utils/match_style_util.dart @@ -1,6 +1,7 @@ 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 { diff --git a/lib/pangea/choreographer/utils/pangea_text_controller.dart b/lib/pangea/choreographer/utils/pangea_text_controller.dart index f3193be62..309f14b3d 100644 --- a/lib/pangea/choreographer/utils/pangea_text_controller.dart +++ b/lib/pangea/choreographer/utils/pangea_text_controller.dart @@ -4,8 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; -import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; +import 'package:fluffychat/pangea/choreographer/enums/pangea_match_status.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/subscription/controllers/subscription_controller.dart'; @@ -31,7 +33,7 @@ class PangeaTextController extends TextEditingController { bool forceKeepOpen = false; - setSystemText(String text, EditType type) { + void setSystemText(String text, EditType type) { editType = type; this.text = text; } @@ -66,46 +68,32 @@ class PangeaTextController extends TextEditingController { return; } - final int matchIndex = - choreographer.igc.igcTextData!.getTopMatchIndexForOffset( + final match = choreographer.igc.igcTextData!.getMatchByOffset( selection.baseOffset, ); + if (match == null) return; // if autoplay on and it start then just start it - if (matchIndex != -1 && - // choreographer.itAutoPlayEnabled && - choreographer.igc.igcTextData!.matches[matchIndex].isITStart) { - return choreographer.onITStart( - choreographer.igc.igcTextData!.matches[matchIndex], - ); + if (match.updatedMatch.isITStart) { + return choreographer.onITStart(match); } - final Widget? cardToShow = matchIndex != -1 - ? SpanCard( - matchIndex: matchIndex, - choreographer: choreographer, - ) - : null; - - if (cardToShow != null) { - MatrixState.pAnyState.closeAllOverlays( - filter: RegExp(r'span_card_overlay_\d+'), - ); - OverlayUtil.showPositionedCard( - overlayKey: matchIndex != -1 ? "span_card_overlay_$matchIndex" : null, - context: context, - maxHeight: matchIndex != -1 && - choreographer.igc.igcTextData!.matches[matchIndex].isITStart - ? 260 - : 400, - maxWidth: 350, - cardToShow: cardToShow, - transformTargetId: choreographer.inputTransformTargetKey, - onDismiss: () => choreographer.setState(), - ignorePointer: true, - isScrollable: false, - ); - } + 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 @@ -145,22 +133,15 @@ class PangeaTextController extends TextEditingController { } else if (choreographer.igc.igcTextData == null || text.isEmpty) { return TextSpan(text: text, style: style); } else { - final parts = text.split(choreographer.igc.igcTextData!.originalInput); + final parts = text.split(choreographer.igc.igcTextData!.currentText); if (parts.length == 1 || parts.length > 2) { return TextSpan(text: text, style: style); } - final choreoSteps = choreographer.choreoRecord?.choreoSteps; - List inlineSpans = []; try { - inlineSpans = choreographer.igc.igcTextData!.constructTokenSpan( - choreoSteps: (choreoSteps?.isNotEmpty ?? false) && - choreoSteps!.last.acceptedOrIgnoredMatch?.status == - PangeaMatchStatus.automatic - ? choreoSteps - : [], + inlineSpans = constructTokenSpan( defaultStyle: style, onUndo: choreographer.onUndoReplacement, ); @@ -181,4 +162,105 @@ class PangeaTextController extends TextEditingController { ); } } + + InlineSpan _matchSpan( + PangeaMatchState match, + TextStyle style, + VoidCallback onUndo, + ) { + if (match.updatedMatch.status == PangeaMatchStatus.automatic) { + final span = choreographer.igc.igcTextData!.currentText.characters + .getRange( + match.updatedMatch.match.offset, + match.updatedMatch.match.offset + match.updatedMatch.match.length, + ) + .toString(); + + final originalText = match.originalMatch.match.fullText.characters + .getRange( + match.originalMatch.match.offset, + match.originalMatch.match.offset + match.originalMatch.match.length, + ) + .toString(); + + return AutocorrectSpan( + transformTargetId: + "autocorrection_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}", + currentText: span, + originalText: originalText, + onUndo: onUndo, + style: style, + ); + } else { + return TextSpan( + text: choreographer.igc.igcTextData!.currentText.characters + .getRange( + match.updatedMatch.match.offset, + match.updatedMatch.match.offset + match.updatedMatch.match.length, + ) + .toString(), + style: style, + ); + } + } + + /// 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({ + required void Function(PangeaMatchState) onUndo, + TextStyle? defaultStyle, + }) { + final automaticMatches = choreographer.igc.igcTextData!.closedMatches + .where((m) => m.updatedMatch.status == PangeaMatchStatus.automatic) + .toList(); + + final textSpanMatches = [ + ...choreographer.igc.igcTextData!.openMatches, + ...automaticMatches, + ]..sort( + (a, b) => + a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset), + ); + + final currentText = choreographer.igc.igcTextData!.currentText; + + final spans = []; + int cursor = 0; + + for (final match in textSpanMatches) { + if (cursor < match.updatedMatch.match.offset) { + final text = currentText.characters + .getRange(cursor, match.updatedMatch.match.offset) + .toString(); + spans.add(TextSpan(text: text, style: defaultStyle)); + } + + final openMatch = + choreographer.igc.igcTextData?.openMatch?.updatedMatch.match; + final style = MatchStyleUtil.textStyle( + match.updatedMatch, + defaultStyle, + openMatch != null && + openMatch.offset == match.updatedMatch.match.offset && + openMatch.length == match.updatedMatch.match.length, + ); + + spans.add(_matchSpan(match, style, () => onUndo.call(match))); + cursor = + match.updatedMatch.match.offset + match.updatedMatch.match.length; + } + + if (cursor < currentText.characters.length) { + spans.add( + TextSpan( + text: currentText.characters + .getRange(cursor, currentText.characters.length) + .toString(), + style: defaultStyle, + ), + ); + } + + return spans; + } } diff --git a/lib/pangea/choreographer/widgets/igc/span_card.dart b/lib/pangea/choreographer/widgets/igc/span_card.dart index fb3a75fa6..49fa74ba9 100644 --- a/lib/pangea/choreographer/widgets/igc/span_card.dart +++ b/lib/pangea/choreographer/widgets/igc/span_card.dart @@ -5,9 +5,8 @@ import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.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_model.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/toolbar/controllers/tts_controller.dart'; import '../../../../widgets/matrix.dart'; import '../../../bot/widgets/bot_face_svg.dart'; @@ -15,12 +14,12 @@ import '../choice_array.dart'; import 'why_button.dart'; class SpanCard extends StatefulWidget { - final int matchIndex; + final PangeaMatchState match; final Choreographer choreographer; const SpanCard({ super.key, - required this.matchIndex, + required this.match, required this.choreographer, }); @@ -30,19 +29,17 @@ class SpanCard extends StatefulWidget { class SpanCardState extends State { bool fetchingData = false; - int? selectedChoiceIndex; final ScrollController scrollController = ScrollController(); @override void initState() { super.initState(); - if (pangeaMatch?.isITStart == true) { + if (widget.match.updatedMatch.isITStart == true) { _onITStart(); return; } getSpanDetails(); - _fetchSelected(); } @override @@ -52,76 +49,19 @@ class SpanCardState extends State { super.dispose(); } - PangeaMatch? get pangeaMatch { - if (widget.choreographer.igc.igcTextData == null) return null; - if (widget.matchIndex >= - widget.choreographer.igc.igcTextData!.matches.length) { - ErrorHandler.logError( - m: "matchIndex out of bounds in span card", - data: { - "matchIndex": widget.matchIndex, - "matchesLength": widget.choreographer.igc.igcTextData?.matches.length, - }, - ); - return null; - } - return widget.choreographer.igc.igcTextData?.matches[widget.matchIndex]; - } - - //get selected choice - SpanChoice? get selectedChoice { - if (selectedChoiceIndex == null) return null; - return _choiceByIndex(selectedChoiceIndex!); - } - - SpanChoice? _choiceByIndex(int index) { - if (pangeaMatch?.match.choices == null || - pangeaMatch!.match.choices!.length <= index) { - return null; - } - return pangeaMatch?.match.choices?[index]; - } - - void _fetchSelected() { - if (pangeaMatch?.match.choices == null) { - return; - } - - // if user ever selected the correct choice, automatically select it - final selectedCorrectIndex = - pangeaMatch!.match.choices!.indexWhere((choice) { - return choice.selected && choice.isBestCorrection; - }); - - if (selectedCorrectIndex != -1) { - selectedChoiceIndex = selectedCorrectIndex; - return; - } - - if (selectedChoiceIndex == null) { - DateTime? mostRecent; - final numChoices = pangeaMatch!.match.choices!.length; - for (int i = 0; i < numChoices; i++) { - final choice = _choiceByIndex(i); - if (choice!.timestamp != null && - (mostRecent == null || choice.timestamp!.isAfter(mostRecent))) { - mostRecent = choice.timestamp; - selectedChoiceIndex = i; - } - } - } - } + SpanChoice? get selectedChoice => + widget.match.updatedMatch.match.selectedChoice; Future getSpanDetails({bool force = false}) async { - if (pangeaMatch?.isITStart ?? false) return; + if (widget.match.updatedMatch.isITStart) return; if (!mounted) return; setState(() { fetchingData = true; }); - await widget.choreographer.igc.spanDataController.getSpanDetails( - widget.matchIndex, + await widget.choreographer.igc.setSpanDetails( + match: widget.match, force: force, ); @@ -131,28 +71,23 @@ class SpanCardState extends State { } void _onITStart() { - if (widget.choreographer.itEnabled && pangeaMatch != null) { - widget.choreographer.onITStart(pangeaMatch!); + if (widget.choreographer.itEnabled) { + widget.choreographer.onITStart(widget.match); } } - Future _onChoiceSelect(int index) async { - selectedChoiceIndex = index; - if (selectedChoice != null) { - selectedChoice!.timestamp = DateTime.now(); - selectedChoice!.selected = true; - setState( - () => (selectedChoice!.isBestCorrection - ? BotExpression.gold - : BotExpression.surprised), - ); - } + void _onChoiceSelect(int index) { + widget.match.selectChoice(index); + setState( + () => (selectedChoice!.isBestCorrection + ? BotExpression.gold + : BotExpression.surprised), + ); } - Future _onReplaceSelected() async { - await widget.choreographer.onReplacementSelect( - matchIndex: widget.matchIndex, - choiceIndex: selectedChoiceIndex!, + Future _onAcceptReplacement() async { + await widget.choreographer.onAcceptReplacement( + match: widget.match, ); _showFirstMatch(); } @@ -161,17 +96,14 @@ class SpanCardState extends State { Future.delayed( Duration.zero, () { - widget.choreographer.onIgnoreMatch( - matchIndex: widget.matchIndex, - ); + widget.choreographer.onIgnoreMatch(match: widget.match); _showFirstMatch(); }, ); } void _showFirstMatch() { - if (widget.choreographer.igc.igcTextData != null && - widget.choreographer.igc.igcTextData!.matches.isNotEmpty) { + if (widget.choreographer.igc.canShowFirstMatch) { widget.choreographer.igc.showFirstMatch(context); } else { MatrixState.pAnyState.closeOverlay(); @@ -199,7 +131,7 @@ class WordMatchContent extends StatelessWidget { @override Widget build(BuildContext context) { - if (controller.pangeaMatch == null || controller.pangeaMatch!.isITStart) { + if (controller.widget.match.updatedMatch.isITStart) { return const SizedBox(); } @@ -218,21 +150,24 @@ class WordMatchContent extends StatelessWidget { children: [ const SizedBox(height: 8), ChoicesArray( - originalSpan: controller.pangeaMatch!.matchContent, + originalSpan: + controller.widget.match.updatedMatch.matchContent, isLoading: controller.fetchingData, - choices: controller.pangeaMatch!.match.choices - ?.map( - (e) => Choice( - text: e.value, - color: e.selected ? e.type.color : null, - isGold: e.type.name == 'bestCorrection', - ), - ) - .toList(), + choices: + controller.widget.match.updatedMatch.match.choices + ?.map( + (e) => Choice( + text: e.value, + color: e.selected ? e.type.color : null, + isGold: e.type.name == 'bestCorrection', + ), + ) + .toList(), onPressed: (value, index) => controller._onChoiceSelect(index), - selectedChoiceIndex: controller.selectedChoiceIndex, - id: controller.pangeaMatch!.hashCode.toString(), + selectedChoiceIndex: controller + .widget.match.updatedMatch.match.selectedChoiceIndex, + id: controller.widget.match.hashCode.toString(), langCode: MatrixState.pangeaController.languageController .activeL2Code(), ), @@ -269,10 +204,10 @@ class WordMatchContent extends StatelessWidget { ), Expanded( child: Opacity( - opacity: controller.selectedChoiceIndex != null ? 1.0 : 0.5, + opacity: controller.selectedChoice != null ? 1.0 : 0.5, child: TextButton( - onPressed: controller.selectedChoiceIndex != null - ? controller._onReplaceSelected + onPressed: controller.selectedChoice != null + ? controller._onAcceptReplacement : null, style: ButtonStyle( backgroundColor: WidgetStateProperty.all( @@ -315,12 +250,8 @@ class PromptAndFeedback extends StatelessWidget { @override Widget build(BuildContext context) { - if (controller.pangeaMatch == null) { - return const SizedBox(); - } - return Container( - constraints: controller.pangeaMatch!.isITStart + constraints: controller.widget.match.updatedMatch.isITStart ? null : const BoxConstraints(minHeight: 75.0), child: Column( @@ -352,10 +283,9 @@ class PromptAndFeedback extends StatelessWidget { loading: controller.fetchingData, ), ], - if (!controller.fetchingData && - controller.selectedChoiceIndex == null) + if (!controller.fetchingData && controller.selectedChoice == null) Text( - controller.pangeaMatch!.match.type.typeName + controller.widget.match.updatedMatch.match.type.typeName .defaultPrompt(context), style: BotStyle.text(context).copyWith( fontStyle: FontStyle.italic, diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index cf670fb23..db23a3e1f 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -343,7 +343,9 @@ class ITChoices extends StatelessWidget { continuance.feedbackText(context), ); } - controller.currentITStep!.continuances[index].wasClicked = true; + controller.currentITStep!.continuances[index] = continuance.copyWith( + wasClicked: true, + ); controller.choreographer.setState(); } diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart index 698801e1f..556c6b26d 100644 --- a/lib/pangea/choreographer/widgets/start_igc_button.dart +++ b/lib/pangea/choreographer/widgets/start_igc_button.dart @@ -62,8 +62,7 @@ class StartIGCButtonState extends State } void _showFirstMatch() { - final igcData = widget.controller.choreographer.igc.igcTextData; - if (igcData != null && igcData.matches.isNotEmpty) { + if (widget.controller.choreographer.igc.canShowFirstMatch) { widget.controller.choreographer.igc.showFirstMatch(context); } } diff --git a/lib/pangea/events/models/representation_content_model.dart b/lib/pangea/events/models/representation_content_model.dart index 4f56bfbd2..f6b5d0e99 100644 --- a/lib/pangea/events/models/representation_content_model.dart +++ b/lib/pangea/events/models/representation_content_model.dart @@ -4,8 +4,8 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.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/pangea_match_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart'; diff --git a/lib/pangea/learning_settings/constants/language_constants.dart b/lib/pangea/learning_settings/constants/language_constants.dart index 972bb3614..3a10b68ef 100644 --- a/lib/pangea/learning_settings/constants/language_constants.dart +++ b/lib/pangea/learning_settings/constants/language_constants.dart @@ -12,7 +12,7 @@ class PrefKey { static const languagesKey = 'p_lang_flag'; } -final LanguageDetection unknownLanguageDetection = LanguageDetection( +const LanguageDetection unknownLanguageDetection = LanguageDetection( langCode: LanguageKeys.unknownLanguage, confidence: 0.5, );