508 lines
15 KiB
Dart
508 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
|
|
import 'package:fluffychat/pages/chat/chat.dart';
|
|
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
|
|
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choregrapher_user_settings_extension.dart';
|
|
import 'package:fluffychat/pangea/choreographer/controllers/extensions/choreographer_state_extension.dart';
|
|
import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart';
|
|
import 'package:fluffychat/pangea/choreographer/controllers/pangea_text_controller.dart';
|
|
import 'package:fluffychat/pangea/choreographer/enums/assistance_state_enum.dart';
|
|
import 'package:fluffychat/pangea/choreographer/enums/choreo_mode.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/pangea_match_state.dart';
|
|
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
|
|
import 'package:fluffychat/pangea/common/utils/error_handler.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/extensions/pangea_room_extension.dart';
|
|
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
|
|
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
|
|
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
|
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
|
import '../../../widgets/matrix.dart';
|
|
import 'error_service.dart';
|
|
import 'it_controller.dart';
|
|
|
|
class OpenMatchesException implements Exception {}
|
|
|
|
class ShowPaywallException implements Exception {}
|
|
|
|
class Choreographer extends ChangeNotifier {
|
|
final PangeaController pangeaController;
|
|
final ChatController chatController;
|
|
|
|
late PangeaTextController textController;
|
|
late ITController itController;
|
|
late IgcController igcController;
|
|
late ErrorService errorService;
|
|
|
|
ChoreoRecord? _choreoRecord;
|
|
|
|
final ValueNotifier<bool> _isFetching = ValueNotifier(false);
|
|
|
|
int _timesClicked = 0;
|
|
Timer? _debounceTimer;
|
|
String? _lastChecked;
|
|
ChoreoMode _choreoMode = ChoreoMode.igc;
|
|
|
|
StreamSubscription? _languageStream;
|
|
StreamSubscription? _settingsUpdateStream;
|
|
|
|
Choreographer(this.pangeaController, this.chatController) {
|
|
_initialize();
|
|
}
|
|
|
|
int get timesClicked => _timesClicked;
|
|
ValueNotifier<bool> get isFetching => _isFetching;
|
|
ChoreoMode get choreoMode => _choreoMode;
|
|
String get currentText => textController.text;
|
|
|
|
void _initialize() {
|
|
textController = PangeaTextController(choreographer: this);
|
|
textController.addListener(_onChange);
|
|
|
|
errorService = ErrorService();
|
|
errorService.addListener(notifyListeners);
|
|
|
|
itController = ITController(
|
|
(e) => errorService.setErrorAndLock(ChoreoError(raw: e)),
|
|
);
|
|
itController.open.addListener(_onCloseIT);
|
|
|
|
igcController = IgcController(
|
|
(e) => errorService.setErrorAndLock(ChoreoError(raw: e)),
|
|
);
|
|
|
|
_languageStream =
|
|
pangeaController.userController.languageStream.stream.listen((update) {
|
|
clear();
|
|
notifyListeners();
|
|
});
|
|
|
|
_settingsUpdateStream =
|
|
pangeaController.userController.settingsUpdateStream.stream.listen((_) {
|
|
notifyListeners();
|
|
});
|
|
clear();
|
|
}
|
|
|
|
void clear() {
|
|
setChoreoMode(ChoreoMode.igc);
|
|
_lastChecked = null;
|
|
_timesClicked = 0;
|
|
_isFetching.value = false;
|
|
_choreoRecord = null;
|
|
itController.clear();
|
|
itController.clearSourceText();
|
|
igcController.clear();
|
|
_resetDebounceTimer();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
itController.dispose();
|
|
errorService.dispose();
|
|
textController.dispose();
|
|
_languageStream?.cancel();
|
|
_settingsUpdateStream?.cancel();
|
|
_debounceTimer?.cancel();
|
|
_isFetching.dispose();
|
|
TtsController.stop();
|
|
}
|
|
|
|
void onPaste(value) {
|
|
_initChoreoRecord();
|
|
_choreoRecord!.pastedStrings.add(value);
|
|
}
|
|
|
|
void onClickSend() {
|
|
if (assistanceState == AssistanceState.fetched) {
|
|
_timesClicked++;
|
|
|
|
// if user is doing IT, call closeIT here to
|
|
// ensure source text is replaced when needed
|
|
if (itController.open.value && _timesClicked > 1) {
|
|
itController.closeIT();
|
|
}
|
|
}
|
|
}
|
|
|
|
void setChoreoMode(ChoreoMode mode) {
|
|
_choreoMode = mode;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _resetDebounceTimer() {
|
|
if (_debounceTimer != null) {
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = null;
|
|
}
|
|
}
|
|
|
|
void _initChoreoRecord() {
|
|
_choreoRecord ??= ChoreoRecord(
|
|
originalText: textController.text,
|
|
choreoSteps: [],
|
|
openMatches: [],
|
|
);
|
|
}
|
|
|
|
void _startLoading() {
|
|
_lastChecked = textController.text;
|
|
_isFetching.value = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _stopLoading() {
|
|
_isFetching.value = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> requestLanguageAssistance() =>
|
|
_startWritingAssistance(manual: true);
|
|
|
|
/// Handles any changes to the text input
|
|
void _onChange() {
|
|
if (_lastChecked != null && _lastChecked == textController.text) {
|
|
return;
|
|
}
|
|
|
|
_lastChecked = textController.text;
|
|
if (textController.editType == EditType.it) {
|
|
return;
|
|
}
|
|
|
|
if (textController.editType == EditType.igc ||
|
|
textController.editType == EditType.itDismissed) {
|
|
textController.editType = EditType.keyboard;
|
|
return;
|
|
}
|
|
|
|
// Close any open IGC overlays
|
|
MatrixState.pAnyState.closeOverlay();
|
|
if (errorService.isError) return;
|
|
|
|
igcController.clear();
|
|
if (textController.editType == EditType.keyboard) {
|
|
itController.clearSourceText();
|
|
}
|
|
|
|
_resetDebounceTimer();
|
|
_debounceTimer ??= Timer(
|
|
const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart),
|
|
() => _startWritingAssistance(),
|
|
);
|
|
|
|
//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;
|
|
}
|
|
|
|
Future<void> _startWritingAssistance({
|
|
bool manual = false,
|
|
}) async {
|
|
if (errorService.isError || isRunningIT) return;
|
|
final SubscriptionStatus canSendStatus =
|
|
pangeaController.subscriptionController.subscriptionStatus;
|
|
|
|
if (canSendStatus != SubscriptionStatus.subscribed ||
|
|
l2Lang == null ||
|
|
l1Lang == null ||
|
|
(!igcEnabled && !itEnabled) ||
|
|
(!isAutoIGCEnabled && !manual && _choreoMode != ChoreoMode.it)) {
|
|
return;
|
|
}
|
|
|
|
_resetDebounceTimer();
|
|
_initChoreoRecord();
|
|
_startLoading();
|
|
await igcController.getIGCTextData(
|
|
textController.text,
|
|
chatController.room.getPreviousMessages(),
|
|
);
|
|
_acceptNormalizationMatches();
|
|
_stopLoading();
|
|
}
|
|
|
|
Future<void> send([int recurrence = 0]) async {
|
|
// 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.value) return;
|
|
|
|
if (errorService.isError) {
|
|
await _sendWithIGC();
|
|
return;
|
|
}
|
|
|
|
if (recurrence > 1) {
|
|
ErrorHandler.logError(
|
|
e: Exception("Choreographer send exceeded max recurrences"),
|
|
level: SentryLevel.warning,
|
|
data: {
|
|
"currentText": chatController.sendController.text,
|
|
"l1LangCode": l1LangCode,
|
|
"l2LangCode": l2LangCode,
|
|
"choreoRecord": _choreoRecord?.toJson(),
|
|
},
|
|
);
|
|
await _sendWithIGC();
|
|
return;
|
|
}
|
|
|
|
if (igcController.canShowFirstMatch) {
|
|
throw OpenMatchesException();
|
|
} 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 subscriptionStatus =
|
|
pangeaController.subscriptionController.subscriptionStatus;
|
|
|
|
if (subscriptionStatus != SubscriptionStatus.subscribed) {
|
|
if (subscriptionStatus == SubscriptionStatus.shouldShowPaywall) {
|
|
throw ShowPaywallException();
|
|
}
|
|
chatController.send(message: chatController.sendController.text);
|
|
return;
|
|
}
|
|
|
|
if (chatController.shouldShowLanguageMismatchPopup) {
|
|
chatController.showLanguageMismatchPopup();
|
|
return;
|
|
}
|
|
|
|
if (!igcController.hasIGCTextData && !itController.dismissed) {
|
|
await _startWritingAssistance();
|
|
// it's possible for this not to be true, i.e. if IGC has an error
|
|
if (igcController.hasIGCTextData) {
|
|
await send(recurrence + 1);
|
|
}
|
|
} else {
|
|
await _sendWithIGC();
|
|
}
|
|
}
|
|
|
|
Future<void> _sendWithIGC() async {
|
|
if (chatController.sendController.text.trim().isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final message = chatController.sendController.text;
|
|
final fakeEventId = chatController.sendFakeMessage();
|
|
|
|
TokensResponseModel? tokensResp;
|
|
if (l1LangCode != null && l2LangCode != null) {
|
|
final res = await pangeaController.messageData
|
|
.getTokens(
|
|
repEventId: null,
|
|
room: chatController.room,
|
|
req: TokensRequestModel(
|
|
fullText: message,
|
|
senderL1: l1LangCode!,
|
|
senderL2: l2LangCode!,
|
|
),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
tokensResp = res.isValue ? res.result : null;
|
|
}
|
|
|
|
final hasOriginalWritten = _choreoRecord?.includedIT == true &&
|
|
itController.sourceText.value != null;
|
|
|
|
chatController.send(
|
|
message: message,
|
|
originalSent: PangeaRepresentation(
|
|
langCode: tokensResp?.detections.firstOrNull?.langCode ??
|
|
LanguageKeys.unknownLanguage,
|
|
text: message,
|
|
originalSent: true,
|
|
originalWritten: hasOriginalWritten,
|
|
),
|
|
originalWritten: hasOriginalWritten
|
|
? PangeaRepresentation(
|
|
langCode: l1LangCode ?? LanguageKeys.unknownLanguage,
|
|
text: itController.sourceText.value!,
|
|
originalWritten: true,
|
|
originalSent: false,
|
|
)
|
|
: null,
|
|
tokensSent: tokensResp != null
|
|
? PangeaMessageTokens(
|
|
tokens: tokensResp.tokens,
|
|
detections: tokensResp.detections,
|
|
)
|
|
: null,
|
|
choreo: _choreoRecord,
|
|
tempEventId: fakeEventId,
|
|
);
|
|
|
|
clear();
|
|
}
|
|
|
|
void openIT(PangeaMatchState itMatch) {
|
|
if (!itMatch.updatedMatch.isITStart) {
|
|
throw Exception("Attempted to open IT with a non-IT start match");
|
|
}
|
|
|
|
chatController.inputFocus.unfocus();
|
|
setChoreoMode(ChoreoMode.it);
|
|
|
|
final sourceText = currentText;
|
|
textController.setSystemText("", EditType.it);
|
|
itController.openIT(sourceText);
|
|
igcController.clear();
|
|
|
|
_initChoreoRecord();
|
|
itMatch.setStatus(PangeaMatchStatus.accepted);
|
|
_choreoRecord!.addRecord(
|
|
"",
|
|
match: itMatch.updatedMatch,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _onCloseIT() {
|
|
if (itController.open.value) return;
|
|
if (currentText.isEmpty && itController.sourceText.value != null) {
|
|
textController.setSystemText(
|
|
itController.sourceText.value!,
|
|
EditType.itDismissed,
|
|
);
|
|
}
|
|
|
|
setChoreoMode(ChoreoMode.igc);
|
|
errorService.resetError();
|
|
notifyListeners();
|
|
}
|
|
|
|
void onSubmitEdits(String text) {
|
|
textController.setSystemText("", EditType.it);
|
|
itController.onSubmitEdits(text);
|
|
}
|
|
|
|
void onAcceptContinuance(int index) {
|
|
final step = itController.onAcceptContinuance(index);
|
|
textController.setSystemText(
|
|
textController.text + step.continuances[step.chosen].text,
|
|
EditType.it,
|
|
);
|
|
|
|
_initChoreoRecord();
|
|
_choreoRecord!.addRecord(textController.text, step: step);
|
|
chatController.inputFocus.requestFocus();
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearMatches(Object error) {
|
|
MatrixState.pAnyState.closeAllOverlays();
|
|
igcController.clearMatches();
|
|
errorService.setError(ChoreoError(raw: error));
|
|
}
|
|
|
|
void onAcceptReplacement({
|
|
required PangeaMatchState match,
|
|
}) {
|
|
final updatedMatch = igcController.acceptReplacement(
|
|
match,
|
|
PangeaMatchStatus.accepted,
|
|
);
|
|
|
|
textController.setSystemText(
|
|
igcController.currentText!,
|
|
EditType.igc,
|
|
);
|
|
|
|
if (!updatedMatch.match.isNormalizationError()) {
|
|
_initChoreoRecord();
|
|
_choreoRecord!.addRecord(
|
|
textController.text,
|
|
match: updatedMatch,
|
|
);
|
|
}
|
|
MatrixState.pAnyState.closeOverlay();
|
|
chatController.inputFocus.requestFocus();
|
|
notifyListeners();
|
|
}
|
|
|
|
void onUndoReplacement(PangeaMatchState match) {
|
|
igcController.undoReplacement(match);
|
|
_choreoRecord?.choreoSteps.removeWhere(
|
|
(step) => step.acceptedOrIgnoredMatch == match.updatedMatch,
|
|
);
|
|
|
|
textController.setSystemText(
|
|
igcController.currentText!,
|
|
EditType.igc,
|
|
);
|
|
MatrixState.pAnyState.closeOverlay();
|
|
chatController.inputFocus.requestFocus();
|
|
notifyListeners();
|
|
}
|
|
|
|
void onIgnoreReplacement({required PangeaMatchState match}) {
|
|
final updatedMatch = igcController.ignoreReplacement(match);
|
|
if (!updatedMatch.match.isNormalizationError()) {
|
|
_initChoreoRecord();
|
|
_choreoRecord!.addRecord(
|
|
textController.text,
|
|
match: updatedMatch,
|
|
);
|
|
}
|
|
MatrixState.pAnyState.closeOverlay();
|
|
chatController.inputFocus.requestFocus();
|
|
notifyListeners();
|
|
}
|
|
|
|
void _acceptNormalizationMatches() {
|
|
final normalizationsMatches = igcController.openNormalizationMatches;
|
|
if (normalizationsMatches?.isEmpty ?? true) return;
|
|
|
|
_initChoreoRecord();
|
|
try {
|
|
for (final match in normalizationsMatches!) {
|
|
match.selectChoice(
|
|
match.updatedMatch.match.choices!.indexWhere(
|
|
(c) => c.isBestCorrection,
|
|
),
|
|
);
|
|
final updatedMatch = igcController.acceptReplacement(
|
|
match,
|
|
PangeaMatchStatus.automatic,
|
|
);
|
|
|
|
textController.setSystemText(
|
|
igcController.currentText!,
|
|
EditType.igc,
|
|
);
|
|
_choreoRecord!.addRecord(
|
|
currentText,
|
|
match: updatedMatch,
|
|
);
|
|
}
|
|
} catch (e, s) {
|
|
ErrorHandler.logError(
|
|
e: e,
|
|
s: s,
|
|
data: {
|
|
"currentText": currentText,
|
|
"l1LangCode": l1LangCode,
|
|
"l2LangCode": l2LangCode,
|
|
"choreoRecord": _choreoRecord?.toJson(),
|
|
},
|
|
);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
}
|