import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/choreographer/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/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/repo/language_detection_repo.dart'; import 'package:fluffychat/pangea/choreographer/utils/input_paste_listener.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_text_controller.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/spaces/models/space_model.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import '../../../widgets/matrix.dart'; import 'error_service.dart'; import 'it_controller.dart'; enum ChoreoMode { igc, it } class Choreographer { PangeaController pangeaController; ChatController chatController; late PangeaTextController _textController; late ITController itController; late IgcController igc; late ErrorService errorService; late TtsController tts; bool isFetching = false; int _timesClicked = 0; final int msBeforeIGCStart = 10000; Timer? debounceTimer; ChoreoRecord choreoRecord = ChoreoRecord.newRecord; // last checked by IGC or translation String? _lastChecked; ChoreoMode choreoMode = ChoreoMode.igc; final StreamController stateStream = StreamController.broadcast(); StreamSubscription? _trialStream; StreamSubscription? _languageStream; late AssistanceState _currentAssistanceState; Choreographer(this.pangeaController, this.chatController) { _initialize(); } _initialize() { tts = TtsController(chatController: chatController); _textController = PangeaTextController(choreographer: this); InputPasteListener(_textController, onPaste); itController = ITController(this); igc = IgcController(this); errorService = ErrorService(this); _textController.addListener(_onChangeListener); _trialStream = pangeaController .subscriptionController.trialActivationStream.stream .listen((_) => _onChangeListener); _languageStream = pangeaController.userController.stateStream.listen((update) { if (update is Map && update['prev_target_lang'] is LanguageModel) { clear(); } // refresh on any profile update, to account // for changes like enabling autocorrect setState(); }); _currentAssistanceState = assistanceState; clear(); } void send(BuildContext context) { debugPrint("can send message: $canSendMessage"); // if isFetching, already called to getLanguageHelp and hasn't completed yet // could happen if user clicked send button multiple times in a row if (isFetching) return; if (igc.igcTextData != null && igc.igcTextData!.matches.isNotEmpty) { igc.showFirstMatch(context); return; } else if (isRunningIT) { // If the user is in the middle of IT, don't send the message. // If they've already clicked the send button once, this will // not be true, so they can still send it if they want. return; } final isSubscribed = pangeaController.subscriptionController.isSubscribed; if (isSubscribed != null && !isSubscribed) { // don't want to run IGC if user isn't subscribed, so either // show the paywall if applicable or just send the message final status = pangeaController.subscriptionController.subscriptionStatus; status == SubscriptionStatus.shouldShowPaywall ? OverlayUtil.showPositionedCard( context: context, cardToShow: PaywallCard( chatController: chatController, ), maxHeight: 325, maxWidth: 325, transformTargetId: inputTransformTargetKey, ) : chatController.send(); return; } if (!igc.hasRelevantIGCTextData && !itController.dismissed) { getLanguageHelp().then((value) => _sendWithIGC(context)); } else { _sendWithIGC(context); } } Future _sendWithIGC(BuildContext context) async { if (!canSendMessage) { // It's possible that the reason user can't send message is because they're in the middle of IT. If this is the case, // do nothing (there's no matches to show). The user can click the send button again to override this. if (!isRunningIT) { igc.showFirstMatch(context); } return; } chatController.sendFakeMessage(); final PangeaRepresentation? originalWritten = choreoRecord.includedIT && itController.sourceText != null ? PangeaRepresentation( langCode: l1LangCode ?? LanguageKeys.unknownLanguage, text: itController.sourceText!, originalWritten: true, originalSent: false, ) : null; final detectionResp = await LanguageDetectionRepo.get( MatrixState.pangeaController.userController.accessToken, request: LanguageDetectionRequest( text: currentText, senderl1: l1LangCode, senderl2: l2LangCode, ), ); final detections = detectionResp.detections; final detectedLanguage = detections.firstOrNull?.langCode ?? LanguageKeys.unknownLanguage; final PangeaRepresentation originalSent = PangeaRepresentation( langCode: detectedLanguage, text: currentText, originalSent: true, originalWritten: originalWritten == null, ); List? res; if (l1LangCode != null && l2LangCode != null) { res = await pangeaController.messageData.getTokens( repEventId: null, room: chatController.room, req: TokensRequestModel( fullText: currentText, langCode: detectedLanguage, senderL1: l1LangCode!, senderL2: l2LangCode!, ), ); } final PangeaMessageTokens? tokensSent = res != null ? PangeaMessageTokens( tokens: res, detections: detections, ) : null; chatController.send( // originalWritten: originalWritten, originalSent: originalSent, tokensSent: tokensSent, //TODO - save originalwritten tokens // choreo: applicableChoreo, choreo: choreoRecord, ); clear(); } _resetDebounceTimer() { if (debounceTimer != null) { debounceTimer?.cancel(); debounceTimer = null; } } void onITStart(PangeaMatch itMatch) { if (!itMatch.isITStart) { throw Exception("this isn't an itStart match!"); } choreoMode = ChoreoMode.it; itController.initializeIT( ITStartData(_textController.text, null), ); itMatch.status = PangeaMatchStatus.accepted; choreoRecord.addRecord(_textController.text, match: itMatch); //PTODO - if totally in L1, save tokens, that's good stuff igc.clear(); _textController.setSystemText("", EditType.itStart); } /// Handles any changes to the text input _onChangeListener() { // Rebuild the IGC button if the state has changed. // This accounts for user typing after initial IGC has completed if (_currentAssistanceState != assistanceState) { setState(); } if (_noChange) { return; } if (_textController.editType == EditType.igc || _textController.editType == EditType.itDismissed) { _lastChecked = _textController.text; _textController.editType = EditType.keyboard; return; } // not sure if this is necessary now MatrixState.pAnyState.closeOverlay(); if (errorService.isError) { return; } igc.clear(); _resetDebounceTimer(); if (editTypeIsKeyboard) { debounceTimer ??= Timer( Duration(milliseconds: msBeforeIGCStart), () => getLanguageHelp(), ); } else { getLanguageHelp(); } //Note: we don't set the keyboard type on each keyboard stroke so this is how we default to //a change being from the keyboard unless explicitly set to one of the other //types when that action happens (e.g. an it/igc choice is selected) textController.editType = EditType.keyboard; } /// Fetches the language help for the current text, including grammar correction, language detection, /// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or /// or if autoIGC is not enabled and the user has not manually requested it. /// [onlyTokensAndLanguageDetection] will Future getLanguageHelp({ bool manual = false, }) async { try { if (errorService.isError) return; final SubscriptionStatus canSendStatus = pangeaController.subscriptionController.subscriptionStatus; if (canSendStatus != SubscriptionStatus.subscribed || l2Lang == null || l1Lang == null || (!igcEnabled && !itEnabled) || (!isAutoIGCEnabled && !manual && choreoMode != ChoreoMode.it)) { return; } startLoading(); // if getting language assistance after finishing IT, // reset the itController if (choreoMode == ChoreoMode.it && itController.isTranslationDone) { itController.clear(); } await (isRunningIT ? itController.getTranslationData(_useCustomInput) : igc.getIGCTextData()); } catch (err, stack) { ErrorHandler.logError( e: err, s: stack, data: { "l2Lang": l2Lang?.toJson(), "l1Lang": l1Lang?.toJson(), "choreoMode": choreoMode, "igcEnabled": igcEnabled, "itEnabled": itEnabled, "isAutoIGCEnabled": isAutoIGCEnabled, "isTranslationDone": itController.isTranslationDone, "useCustomInput": _useCustomInput, }, ); } finally { stopLoading(); } } void onITChoiceSelect(ITStep step) { choreoRecord.addRecord(_textController.text, step: step); _textController.setSystemText( _textController.text + step.continuances[step.chosen!].text, step.continuances[step.chosen!].gold ? EditType.itGold : EditType.itStandard, ); _textController.selection = TextSelection.collapsed(offset: _textController.text.length); giveInputFocus(); } Future onReplacementSelect({ required int matchIndex, required int choiceIndex, }) async { try { if (igc.igcTextData == null) { ErrorHandler.logError( e: "onReplacementSelect with null igcTextData", s: StackTrace.current, data: { "matchIndex": matchIndex, "choiceIndex": choiceIndex, }, ); MatrixState.pAnyState.closeOverlay(); return; } if (igc.igcTextData!.matches[matchIndex].match.choices == null) { ErrorHandler.logError( e: "onReplacementSelect with null choices", s: StackTrace.current, data: { "igctextData": igc.igcTextData?.toJson(), "matchIndex": matchIndex, "choiceIndex": choiceIndex, }, ); 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); //if it's the right choice, replace in text if (!isNormalizationError) { choreoRecord.addRecord( _textController.text, match: igc.igcTextData!.matches[matchIndex].copyWith ..status = PangeaMatchStatus.accepted, ); } igc.igcTextData!.acceptReplacement( matchIndex, choiceIndex, ); _textController.setSystemText( igc.igcTextData!.originalInput, EditType.igc, ); MatrixState.pAnyState.closeOverlay(); setState(); } catch (err, stack) { debugger(when: kDebugMode); ErrorHandler.logError( e: err, s: stack, data: { "igctextData": igc.igcTextData?.toJson(), "matchIndex": matchIndex, "choiceIndex": choiceIndex, }, ); igc.igcTextData?.matches.clear(); } finally { setState(); } } void acceptNormalizationMatches() { for (int i = 0; i < igc.igcTextData!.matches.length; i++) { final isNormalizationError = igc.spanDataController.isNormalizationError(i); if (!isNormalizationError) continue; final match = igc.igcTextData!.matches[i]; choreoRecord.addRecord( _textController.text, match: match.copyWith..status = PangeaMatchStatus.automatic, ); igc.igcTextData!.acceptReplacement( i, match.match.choices!.indexWhere( (c) => c.isBestCorrection, ), ); _textController.setSystemText( igc.igcTextData!.originalInput, EditType.igc, ); } } void onIgnoreMatch({required int cursorOffset}) { try { if (igc.igcTextData == null) { debugger(when: kDebugMode); ErrorHandler.logError( m: "should not be in onIgnoreMatch with null igcTextData", s: StackTrace.current, data: {}, ); return; } final int matchIndex = igc.igcTextData!.getTopMatchIndexForOffset( cursorOffset, ); if (matchIndex == -1) { debugger(when: kDebugMode); throw Exception("Cannot find the ignored match in igcTextData"); } igc.onIgnoreMatch(igc.igcTextData!.matches[matchIndex]); igc.igcTextData!.matches[matchIndex].status = PangeaMatchStatus.ignored; final isNormalizationError = igc.spanDataController.isNormalizationError(matchIndex); if (!isNormalizationError) { choreoRecord.addRecord( _textController.text, match: igc.igcTextData!.matches[matchIndex], ); } igc.igcTextData!.matches.removeAt(matchIndex); } catch (err, stack) { debugger(when: kDebugMode); Sentry.addBreadcrumb( Breadcrumb( data: { "igcTextData": igc.igcTextData?.toJson(), "offset": cursorOffset, }, ), ); ErrorHandler.logError( e: err, s: stack, data: { "igctextData": igc.igcTextData?.toJson(), }, ); igc.igcTextData?.matches.clear(); } finally { setState(); } } void giveInputFocus() { Future.delayed(Duration.zero, () { chatController.inputFocus.requestFocus(); }); } String get currentText => _textController.text; PangeaTextController get textController => _textController; String get accessToken => pangeaController.userController.accessToken; clear() { choreoMode = ChoreoMode.igc; _lastChecked = null; _timesClicked = 0; isFetching = false; choreoRecord = ChoreoRecord.newRecord; itController.clear(); igc.dispose(); //@ggurdin - why is this commented out? // errorService.clear(); _resetDebounceTimer(); } Future onPaste(value) async { choreoRecord.pastedStrings.add(value); } dispose() { _textController.dispose(); _trialStream?.cancel(); _languageStream?.cancel(); stateStream.close(); tts.dispose(); } LanguageModel? get l2Lang { return pangeaController.languageController.activeL2Model(); } String? get l2LangCode => l2Lang?.langCode; LanguageModel? get l1Lang => pangeaController.languageController.activeL1Model(); String? get l1LangCode => l1Lang?.langCode; String? get userId => pangeaController.userController.userId; bool get _noChange => _lastChecked != null && _lastChecked == _textController.text; bool get isRunningIT => choreoMode == ChoreoMode.it && !itController.isTranslationDone; void startLoading() { _lastChecked = _textController.text; isFetching = true; setState(); } void stopLoading() { isFetching = false; setState(); } void incrementTimesClicked() { if (assistanceState == AssistanceState.fetched) { _timesClicked++; // if user is doing IT, call closeIT here to // ensure source text is replaced when needed if (itController.isOpen && _timesClicked > 1) { itController.closeIT(); } } } get roomId => chatController.roomId; bool get _useCustomInput => [ EditType.keyboard, EditType.igc, EditType.alternativeTranslation, ].contains(_textController.editType); bool get editTypeIsKeyboard => EditType.keyboard == _textController.editType; setState() { if (!stateStream.isClosed) { stateStream.add(0); } _currentAssistanceState = assistanceState; } LayerLinkAndKey get itBarLinkAndKey => MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey); String get itBarTransformTargetKey => 'it_bar$roomId'; LayerLinkAndKey get inputLayerLinkAndKey => MatrixState.pAnyState.layerLinkAndKey(inputTransformTargetKey); String get inputTransformTargetKey => 'input$roomId'; LayerLinkAndKey get itBotLayerLinkAndKey => MatrixState.pAnyState.layerLinkAndKey(itBotTransformTargetKey); String get itBotTransformTargetKey => 'itBot$roomId'; bool get igcEnabled => pangeaController.permissionsController.isToolEnabled( ToolSetting.interactiveGrammar, chatController.room, ); bool get itEnabled => pangeaController.permissionsController.isToolEnabled( ToolSetting.interactiveTranslator, chatController.room, ); bool get immersionMode => pangeaController.permissionsController.isToolEnabled( ToolSetting.immersionMode, chatController.room, ); bool get isAutoIGCEnabled => pangeaController.permissionsController.isToolEnabled( ToolSetting.autoIGC, chatController.room, ); AssistanceState get assistanceState { final isSubscribed = pangeaController.subscriptionController.isSubscribed; if (isSubscribed != null && !isSubscribed) { return AssistanceState.noSub; } if (currentText.isEmpty && itController.sourceText == null) { return AssistanceState.noMessage; } if ((igc.igcTextData?.matches.isNotEmpty ?? false) || isRunningIT) { return AssistanceState.fetched; } if (isFetching) { return AssistanceState.fetching; } if (igc.igcTextData == null) { return AssistanceState.notFetched; } return AssistanceState.complete; } bool get canSendMessage { // if there's an error, let them send. we don't want to block them from sending in this case if (errorService.isError || l2Lang == null || l1Lang == null || _timesClicked > 1) { return true; } // if they're in IT mode, don't let them send if (itEnabled && isRunningIT) return false; // if they've turned off IGC then let them send the message when they want if (!isAutoIGCEnabled) return true; // if we're in the middle of fetching results, don't let them send if (isFetching) return false; // they're supposed to run IGC but haven't yet, don't let them send if (igc.igcTextData == null) { return itController.dismissed; } // if they have relevant matches, don't let them send final hasITMatches = igc.igcTextData!.matches.any((match) => match.isITStart); final hasIGCMatches = igc.igcTextData!.matches.any((match) => !match.isITStart); if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) { return false; } // otherwise, let them send return true; } }