immutable data with separate stateful models

This commit is contained in:
ggurdin 2025-10-28 14:26:14 -04:00
parent 162350d469
commit 0db2c70ef4
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
29 changed files with 1063 additions and 887 deletions

View file

@ -2196,8 +2196,7 @@ class ChatController extends State<ChatPageWithRoom>
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);
}
},

View file

@ -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<void> onReplacementSelect({
required int matchIndex,
required int choiceIndex,
Future<void> 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<int> 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;
}

View file

@ -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<void> 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<PangeaMatch> 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<void> 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();
}
}

View file

@ -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<Continuance>.from(responseModel.continuances);
}
}
}

View file

@ -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<void> 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();
}
}

View file

@ -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;
}
}
}

View file

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

View file

@ -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<ChoreoRecordStep> choreoSteps;
List<PangeaMatch> openMatches;
final List<ChoreoRecordStep> choreoSteps;
final List<PangeaMatch> openMatches;
final String originalText;
final Set<String> 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,

View file

@ -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<PangeaMatch> matches;
String userL1;
String userL2;
bool enableIT;
bool enableIGC;
final String _originalInput;
final List<PangeaMatch> _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<String, dynamic> json) {
return IGCTextData(
matches: json["matches"] != null
? (json["matches"] as Iterable)
.map<PangeaMatch>(
(e) {
return PangeaMatch.fromJson(e as Map<String, dynamic>);
},
)
.toList()
.cast<PangeaMatch>()
: [],
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<PangeaMatch> matches,
}) : _state = IGCTextState(
currentText: originalInput,
matches: List<PangeaMatch>.from(matches),
),
_originalInput = originalInput,
_matches = matches;
Map<String, dynamic> 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<int> _matchIndicesByOffset(int offset) {
final List<int> 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<PangeaMatchState> get openMatches => _state.openMatches;
List<PangeaMatchState> get closedMatches => _state.closedMatches;
PangeaMatchState? get firstOpenMatch => _state.firstOpenMatch;
PangeaMatchState? get openMatch => _state.openMatch;
PangeaMatchState? getMatchByOffset(int offset) =>
_state.getMatchByOffset(offset);
List<PangeaMatchState> 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<InlineSpan> constructTokenSpan({
required List<ChoreoRecordStep> choreoSteps,
void Function(PangeaMatch)? onUndo,
TextStyle? defaultStyle,
}) {
final automaticMatches = choreoSteps
.map((s) => s.acceptedOrIgnoredMatch)
.whereType<PangeaMatch>()
.where((m) => m.status == PangeaMatchStatus.automatic)
.toList();
final textSpanMatches = [...matches, ...automaticMatches]
..sort((a, b) => a.match.offset.compareTo(b.match.offset));
final spans = <InlineSpan>[];
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);
}

View file

@ -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<PangeaMatchState> _openMatches = [];
final List<PangeaMatchState> _closedMatches = [];
IGCTextState({
required String currentText,
required List<PangeaMatch> 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<PangeaMatchState> get openMatches => _openMatches;
List<PangeaMatchState> get closedMatches => _closedMatches;
List<PangeaMatchState> 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);
}
}
}

View file

@ -4,10 +4,10 @@ import 'package:fluffychat/l10n/l10n.dart';
import '../constants/choreo_constants.dart';
class ITStep {
List<Continuance> continuances;
int? chosen;
String? customInput;
bool showAlternativeTranslationOption = false;
final List<Continuance> 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<PangeaToken> 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<String, dynamic> json) {
// final List<PangeaToken> tokensInternal = (json[ModelKey.tokens] != null)
// ? (json[ModelKey.tokens] as Iterable)
// .map<PangeaToken>(
// (e) => PangeaToken.fromJson(e as Map<String, dynamic>),
// )
// .toList()
// .cast<PangeaToken>()
// : [];
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) {

View file

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

View file

@ -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;
}
}

View file

@ -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<SpanChoice>.from(_match.choices ?? []);
choices[index] = choices[index].copyWith(
selected: true,
timestamp: DateTime.now(),
);
setMatch(_match.copyWith(choices: choices));
}
Map<String, dynamic> toJson() {
return {
'originalMatch': _original.toJson(),
'match': _match.toJson(),
'status': _status.toString(),
};
}
}

View file

@ -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<SpanChoice>? 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<SpanChoice>? 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<String, dynamic> json) {
final Iterable? choices = json['choices'] ?? json['replacements'];
return SpanData(
@ -44,15 +76,6 @@ class SpanData {
);
}
String? message;
String? shortMessage;
List<SpanChoice>? choices;
int offset;
int length;
String fullText;
SpanDataType type;
Rule? rule;
Map<String, dynamic> 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<PangeaToken> 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<String, dynamic> json) {
// final List<PangeaToken> tokensInternal = (json[ModelKey.tokens] != null)
// ? (json[ModelKey.tokens] as Iterable)
// .map<PangeaToken>(
// (e) => PangeaToken.fromJson(e as Map<String, dynamic>),
// )
// .toList()
// .cast<PangeaToken>()
// : [];
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<String, dynamic> json) => Rule(
id: json['id'] as String,
);
String id;
Map<String, dynamic> 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<String, dynamic> 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;
}
}

View file

@ -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<IGCTextData> data;
final Future<IGCResponseModel> data;
final DateTime timestamp;
const _IgcCacheItem({
@ -52,7 +52,7 @@ class IgcRepo {
static final Map<String, _IgnoredMatchCacheItem> _ignoredMatchCache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<IGCTextData>> get(
static Future<Result<IGCResponseModel>> get(
String? accessToken,
IGCRequestModel igcRequest,
) {
@ -69,7 +69,7 @@ class IgcRepo {
return _getResult(igcRequest, future);
}
static Future<IGCTextData> _fetch(
static Future<IGCResponseModel> _fetch(
String? accessToken, {
required IGCRequestModel igcRequest,
}) async {
@ -91,12 +91,12 @@ class IgcRepo {
final Map<String, dynamic> json =
jsonDecode(utf8.decode(res.bodyBytes).toString());
return IGCTextData.fromJson(json);
return IGCResponseModel.fromJson(json);
}
static Future<Result<IGCTextData>> _getResult(
static Future<Result<IGCResponseModel>> _getResult(
IGCRequestModel request,
Future<IGCTextData> future,
Future<IGCResponseModel> future,
) async {
try {
final res = await future;
@ -112,7 +112,7 @@ class IgcRepo {
}
}
static Future<IGCTextData>? _getCached(
static Future<IGCResponseModel>? _getCached(
IGCRequestModel request,
) {
final cacheKeys = [..._igcCache.keys];
@ -129,7 +129,7 @@ class IgcRepo {
static void _setCached(
IGCRequestModel request,
Future<IGCTextData> response,
Future<IGCResponseModel> response,
) =>
_igcCache[request.hashCode.toString()] = _IgcCacheItem(
data: response,

View file

@ -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<PangeaMatch> 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<String, dynamic> json) {
return IGCResponseModel(
matches: json["matches"] != null
? (json["matches"] as Iterable)
.map<PangeaMatch>(
(e) {
return PangeaMatch.fromJson(e as Map<String, dynamic>);
},
)
.toList()
.cast<PangeaMatch>()
: [],
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<String, dynamic> 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,
};
}

View file

@ -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<Result<ITResponseModel>> get(
CustomInputRequestModel request,
ITRequestModel request,
) {
final cached = _getCached(request);
if (cached != null) {
@ -39,7 +39,7 @@ class ITRepo {
}
static Future<ITResponseModel> _fetch(
CustomInputRequestModel request,
ITRequestModel request,
) async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
@ -57,7 +57,7 @@ class ITRepo {
}
static Future<Result<ITResponseModel>> _getResult(
CustomInputRequestModel request,
ITRequestModel request,
Future<ITResponseModel> future,
) async {
try {
@ -75,7 +75,7 @@ class ITRepo {
}
static Future<ITResponseModel>? _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<ITResponseModel> response,
) {
_cache[request.hashCode.toString()] = _ITCacheItem(

View file

@ -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<Continuance>? 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 &&

View file

@ -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<SpanDetailsRepoReqAndRes> data;
final Future<SpanDetailsResponse> data;
final DateTime timestamp;
const _SpanDetailsCacheItem({
@ -25,9 +24,9 @@ class SpanDataRepo {
static final Map<String, _SpanDetailsCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<SpanDetailsRepoReqAndRes>> get(
static Future<Result<SpanDetailsResponse>> 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<SpanDetailsRepoReqAndRes> _fetch(
static Future<SpanDetailsResponse> _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<String, dynamic> 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<Result<SpanDetailsRepoReqAndRes>> _getResult(
SpanDetailsRepoReqAndRes request,
Future<SpanDetailsRepoReqAndRes> future,
static Future<Result<SpanDetailsResponse>> _getResult(
SpanDetailsRequest request,
Future<SpanDetailsResponse> future,
) async {
try {
final res = await future;
@ -79,8 +82,8 @@ class SpanDataRepo {
}
}
static Future<SpanDetailsRepoReqAndRes>? _getCached(
SpanDetailsRepoReqAndRes request,
static Future<SpanDetailsResponse>? _getCached(
SpanDetailsRequest request,
) {
final cacheKeys = [..._cache.keys];
for (final key in cacheKeys) {
@ -92,8 +95,8 @@ class SpanDataRepo {
}
static void _setCached(
SpanDetailsRepoReqAndRes request,
Future<SpanDetailsRepoReqAndRes> response,
SpanDetailsRequest request,
Future<SpanDetailsResponse> 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<String, dynamic> toJson() => {
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
"enable_it": enableIT,
"enable_igc": enableIGC,
'span': span.toJson(),
};
factory SpanDetailsRepoReqAndRes.fromJson(Map<String, dynamic> 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),
),
]);
}
}

View file

@ -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<String, dynamic> 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;
}
}

View file

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

View file

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

View file

@ -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<InlineSpan> 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<InlineSpan> 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 = <InlineSpan>[];
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;
}
}

View file

@ -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<SpanCard> {
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<SpanCard> {
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<void> 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<SpanCard> {
}
void _onITStart() {
if (widget.choreographer.itEnabled && pangeaMatch != null) {
widget.choreographer.onITStart(pangeaMatch!);
if (widget.choreographer.itEnabled) {
widget.choreographer.onITStart(widget.match);
}
}
Future<void> _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<void> _onReplaceSelected() async {
await widget.choreographer.onReplacementSelect(
matchIndex: widget.matchIndex,
choiceIndex: selectedChoiceIndex!,
Future<void> _onAcceptReplacement() async {
await widget.choreographer.onAcceptReplacement(
match: widget.match,
);
_showFirstMatch();
}
@ -161,17 +96,14 @@ class SpanCardState extends State<SpanCard> {
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<Color>(
@ -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,

View file

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

View file

@ -62,8 +62,7 @@ class StartIGCButtonState extends State<StartIGCButton>
}
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);
}
}

View file

@ -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';

View file

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