diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index e820f9f88..87231b034 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -303,13 +303,15 @@ class ChatController extends State return; } if (!scrollController.hasClients) return; - if (timeline?.allowNewEvent == false || - scrollController.position.pixels > 0 && _scrolledUp == false) { - setState(() => _scrolledUp = true); - } else if (scrollController.position.pixels <= 0 && _scrolledUp == true) { - setState(() => _scrolledUp = false); - setReadMarker(); - } + // #Pangea + // if (timeline?.allowNewEvent == false || + // scrollController.position.pixels > 0 && _scrolledUp == false) { + // setState(() => _scrolledUp = true); + // } else if (scrollController.position.pixels <= 0 && _scrolledUp == true) { + // setState(() => _scrolledUp = false); + // setReadMarker(); + // } + // Pangea# if (scrollController.position.pixels == 0 || scrollController.position.pixels == 64) { @@ -789,6 +791,8 @@ class ChatController extends State _botAudioSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); carouselController.dispose(); + scrollController.dispose(); + inputFocus.dispose(); TokensUtil.clearNewTokenCache(); //Pangea# super.dispose(); @@ -867,6 +871,7 @@ class ChatController extends State inReplyTo: replyEvent, editEventId: editEvent?.eventId, ); + inputFocus.unfocus(); sendController.setSystemText("", EditType.other); setState(() => _fakeEventIDs.add(eventID)); @@ -1697,7 +1702,7 @@ class ChatController extends State void onSelectMessage(Event event) { // #Pangea - if (choreographer.isITOpen) { + if (choreographer.itController.open.value) { return; } // Pangea# @@ -1751,14 +1756,20 @@ class ChatController extends State await choreographer.send(); } on ShowPaywallException { PaywallCard.show(context, choreographer.inputTransformTargetKey); + return; } on OpenMatchesException { - if (choreographer.firstIGCMatch != null) { - OverlayUtil.showIGCMatch( - choreographer.firstIGCMatch!, - choreographer, - context, - ); + if (choreographer.firstOpenMatch != null) { + if (choreographer.firstOpenMatch!.updatedMatch.isITStart) { + choreographer.openIT(choreographer.firstOpenMatch!); + } else { + OverlayUtil.showIGCMatch( + choreographer.firstOpenMatch!, + choreographer, + context, + ); + } } + return; } // Pangea# FocusScope.of(context).requestFocus(inputFocus); @@ -2064,13 +2075,6 @@ class ChatController extends State }); } - double inputBarHeight = 64; - void updateInputBarHeight(double height) { - if (mounted && height != inputBarHeight) { - setState(() => inputBarHeight = height); - } - } - bool get displayChatDetailsColumn { try { return _displayChatDetailsColumn.value; @@ -2207,15 +2211,27 @@ class ChatController extends State OverlayUtil.showPositionedCard( context: context, cardToShow: LanguageMismatchPopup( - targetLanguage: targetLanguage, - onUpdate: () async { - final igcMatch = await choreographer.requestLanguageAssistance(); - if (igcMatch != null) { - OverlayUtil.showIGCMatch( - igcMatch, - choreographer, - context, - ); + onConfirm: () async { + await MatrixState.pangeaController.userController.updateProfile( + (profile) { + profile.userSettings.targetLanguage = targetLanguage; + return profile; + }, + waitForDataInSync: true, + ); + + await choreographer.requestLanguageAssistance(); + final openMatch = choreographer.firstOpenMatch; + if (openMatch != null) { + if (openMatch.updatedMatch.isITStart) { + choreographer.openIT(openMatch); + } else { + OverlayUtil.showIGCMatch( + openMatch, + choreographer, + context, + ); + } } }, ), @@ -2246,9 +2262,10 @@ class ChatController extends State targetAnchor: Alignment.topRight, context: context, child: MessageAnalyticsFeedback( - overlayId: "msg_analytics_feedback_$eventId", newGrammarConstructs: newGrammarConstructs, newVocabConstructs: newVocabConstructs, + close: () => MatrixState.pAnyState + .closeOverlay("msg_analytics_feedback_$eventId"), ), transformTargetId: eventId, ignorePointer: true, diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 44a2a3564..7a4f57c38 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -44,13 +44,13 @@ class Choreographer extends ChangeNotifier { ChoreoRecord? _choreoRecord; - bool _isFetching = false; + final ValueNotifier _isFetching = ValueNotifier(false); int _timesClicked = 0; Timer? _debounceTimer; String? _lastChecked; ChoreoMode _choreoMode = ChoreoMode.igc; - String? _sourceText; + final ValueNotifier _sourceText = ValueNotifier(null); StreamSubscription? _languageStream; StreamSubscription? _settingsUpdateStream; @@ -60,10 +60,10 @@ class Choreographer extends ChangeNotifier { } int get timesClicked => _timesClicked; - bool get isFetching => _isFetching; + ValueNotifier get isFetching => _isFetching; ChoreoMode get choreoMode => _choreoMode; - String? get sourceText => _sourceText; + ValueNotifier get sourceText => _sourceText; String get currentText => textController.text; void _initialize() { @@ -94,9 +94,9 @@ class Choreographer extends ChangeNotifier { _choreoMode = ChoreoMode.igc; _lastChecked = null; _timesClicked = 0; - _isFetching = false; + _isFetching.value = false; _choreoRecord = null; - _sourceText = null; + _sourceText.value = null; itController.clear(); igc.clear(); _resetDebounceTimer(); @@ -109,6 +109,8 @@ class Choreographer extends ChangeNotifier { textController.dispose(); _languageStream?.cancel(); _settingsUpdateStream?.cancel(); + _debounceTimer?.cancel(); + _isFetching.dispose(); TtsController.stop(); } @@ -123,7 +125,7 @@ class Choreographer extends ChangeNotifier { // if user is doing IT, call closeIT here to // ensure source text is replaced when needed - if (isITOpen && _timesClicked > 1) { + if (itController.open.value && _timesClicked > 1) { closeIT(); } } @@ -151,27 +153,42 @@ class Choreographer extends ChangeNotifier { void _startLoading() { _lastChecked = textController.text; - _isFetching = true; + _isFetching.value = true; notifyListeners(); } void _stopLoading() { - _isFetching = false; + _isFetching.value = false; notifyListeners(); } - Future requestLanguageAssistance() async { - await _getLanguageAssistance(manual: true); - if (igc.canShowFirstMatch) { - return igc.onShowFirstMatch(); - } - return null; - } + Future requestLanguageAssistance() => + _getLanguageAssistance(manual: true); - Future send() async { + Future 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) return; + 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 (igc.canShowFirstMatch) { throw OpenMatchesException(); @@ -200,9 +217,12 @@ class Choreographer extends ChangeNotifier { if (!igc.hasIGCTextData && !itController.dismissed) { await _getLanguageAssistance(); - await send(); + // it's possible for this not to be true, i.e. if IGC has an error + if (igc.hasIGCTextData) { + await send(recurrence + 1); + } } else { - _sendWithIGC(); + await _sendWithIGC(); } } @@ -230,7 +250,7 @@ class Choreographer extends ChangeNotifier { if (textController.editType == EditType.it) { _getLanguageAssistance(); } else { - _sourceText = null; + _sourceText.value = null; _debounceTimer ??= Timer( const Duration(milliseconds: ChoreoConstants.msBeforeIGCStart), () => _getLanguageAssistance(), @@ -278,10 +298,10 @@ class Choreographer extends ChangeNotifier { final message = chatController.sendController.text; final fakeEventId = chatController.sendFakeMessage(); final PangeaRepresentation? originalWritten = - _choreoRecord?.includedIT == true && _sourceText != null + _choreoRecord?.includedIT == true && _sourceText.value != null ? PangeaRepresentation( langCode: l1LangCode ?? LanguageKeys.unknownLanguage, - text: _sourceText!, + text: _sourceText.value!, originalWritten: true, originalSent: false, ) @@ -348,21 +368,21 @@ class Choreographer extends ChangeNotifier { if (!itMatch.updatedMatch.isITStart) { throw Exception("Attempted to open IT with a non-IT start match"); } + chatController.inputFocus.unfocus(); - _choreoMode = ChoreoMode.it; - _sourceText = textController.text; - itController.openIT(); - - igc.clear(); + setChoreoMode(ChoreoMode.it); + _sourceText.value = textController.text; textController.setSystemText("", EditType.it); + itController.openIT(); + igc.clear(); + _initChoreoRecord(); itMatch.setStatus(PangeaMatchStatus.accepted); _choreoRecord!.addRecord( - textController.text, + "", match: itMatch.updatedMatch, ); - notifyListeners(); } void closeIT() { @@ -394,7 +414,7 @@ class Choreographer extends ChangeNotifier { } void setSourceText(String? text) { - _sourceText = text; + _sourceText.value = text; } void setEditingSourceText(bool value) { @@ -403,7 +423,8 @@ class Choreographer extends ChangeNotifier { } void submitSourceTextEdits(String text) { - _sourceText = text; + _sourceText.value = text; + textController.setSystemText("", EditType.it); itController.onSubmitEdits(); notifyListeners(); } diff --git a/lib/pangea/choreographer/controllers/it_controller.dart b/lib/pangea/choreographer/controllers/it_controller.dart index df88f4704..548c0cddb 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -19,28 +19,27 @@ import 'choreographer.dart'; class ITController { final Choreographer _choreographer; - ITStep? _currentITStep; + ValueNotifier _currentITStep = ValueNotifier(null); final List> _queue = []; GoldRouteTracker? _goldRouteTracker; - bool _open = false; - bool _editing = false; + final ValueNotifier _open = ValueNotifier(false); + final ValueNotifier _editing = ValueNotifier(false); bool _dismissed = false; ITController(this._choreographer); - bool get open => _open; - bool get editing => _editing; + ValueNotifier get open => _open; + ValueNotifier get editing => _editing; bool get dismissed => _dismissed; - List? get continuances => _currentITStep?.continuances; - bool get isTranslationDone => _currentITStep?.isFinal ?? false; + ValueNotifier get currentITStep => _currentITStep; - String? get _sourceText => _choreographer.sourceText; + ValueNotifier get _sourceText => _choreographer.sourceText; ITRequestModel _request(String textInput) { - assert(_sourceText != null); + assert(_sourceText.value != null); return ITRequestModel( - text: _sourceText!, + text: _sourceText.value!, customInput: textInput, sourceLangCode: MatrixState.pangeaController.languageController.activeL1Code()!, @@ -53,13 +52,15 @@ class ITController { ); } - void openIT() => _open = true; + void openIT() { + _open.value = true; + } void closeIT() { // if the user hasn't gone through any IT steps, reset the text - if (_choreographer.currentText.isEmpty && _sourceText != null) { + if (_choreographer.currentText.isEmpty && _sourceText.value != null) { _choreographer.textController.setSystemText( - _sourceText!, + _sourceText.value!, EditType.itDismissed, ); } @@ -70,77 +71,81 @@ class ITController { void clear({bool dismissed = false}) { MatrixState.pAnyState.closeOverlay("it_feedback_card"); - _open = false; - _editing = false; + _open.value = false; + _editing.value = false; _dismissed = dismissed; _queue.clear(); - _currentITStep = null; + _currentITStep = ValueNotifier(null); _goldRouteTracker = null; _choreographer.setChoreoMode(ChoreoMode.igc); _choreographer.setSourceText(null); } - void setEditing(bool value) => _editing = value; + void setEditing(bool value) { + _editing.value = value; + } void onSubmitEdits() { - _editing = false; + _editing.value = false; _queue.clear(); - _currentITStep = null; + _currentITStep = ValueNotifier(null); _goldRouteTracker = null; continueIT(); } Continuance onSelectContinuance(int index) { - if (_currentITStep == null) { - throw "onSelectContinuance called with null currentITStep"; + if (_currentITStep.value == null) { + throw "onSelectContinuance called when _currentITStep is null"; } - if (index < 0 || index >= _currentITStep!.continuances.length) { + if (index < 0 || index >= _currentITStep.value!.continuances.length) { throw "onSelectContinuance called with invalid index $index"; } - final step = _currentITStep!.continuances[index]; - _currentITStep!.continuances[index] = step.copyWith( + final currentStep = _currentITStep.value!; + currentStep.continuances[index] = currentStep.continuances[index].copyWith( wasClicked: true, ); - return _currentITStep!.continuances[index]; + _currentITStep.value = _currentITStep.value!.copyWith( + continuances: currentStep.continuances, + ); + return _currentITStep.value!.continuances[index]; } CompletedITStep getAcceptedITStep(int chosenIndex) { - if (_currentITStep == null) { - throw "getAcceptedITStep called with null currentITStep"; + if (_currentITStep.value == null) { + throw "getAcceptedITStep called when _currentITStep is null"; } - if (chosenIndex < 0 || chosenIndex >= _currentITStep!.continuances.length) { + if (chosenIndex < 0 || + chosenIndex >= _currentITStep.value!.continuances.length) { throw "getAcceptedITStep called with invalid index $chosenIndex"; } return CompletedITStep( - _currentITStep!.continuances, + _currentITStep.value!.continuances, chosen: chosenIndex, ); } Future continueIT() async { - if (_currentITStep == null) { + if (_currentITStep.value == null) { await _initTranslationData(); return; } - if (_queue.isEmpty) { _choreographer.closeIT(); - return; - } - - final nextStepCompleter = _queue.removeAt(0); - try { - _currentITStep = await nextStepCompleter.future; - } catch (e) { - if (_open) { - _choreographer.errorService.setErrorAndLock( - ChoreoError(raw: e), - ); + } else { + try { + final nextStepCompleter = _queue.removeAt(0); + _currentITStep.value = await nextStepCompleter.future; + } catch (e) { + if (_open.value) { + _choreographer.errorService.setErrorAndLock( + ChoreoError(raw: e), + ); + } } } } @@ -156,7 +161,7 @@ class ITController { }, ); - if (_sourceText == null || !_open) return; + if (_sourceText.value == null || !_open.value) return; if (res.isError || res.result?.goldContinuances == null) { _choreographer.errorService.setErrorAndLock( ChoreoError(raw: res.asError), @@ -167,11 +172,11 @@ class ITController { final result = res.result!; _goldRouteTracker = GoldRouteTracker( result.goldContinuances!, - _sourceText!, + _sourceText.value!, ); - _currentITStep = ITStep( - sourceText: _sourceText!, + _currentITStep.value = ITStep.fromResponse( + sourceText: _sourceText.value!, currentText: currentText, responseModel: res.result!, storedGoldContinuances: _goldRouteTracker!.continuances, @@ -181,11 +186,13 @@ class ITController { } Future _fillITStepQueue() async { - if (_sourceText == null || _goldRouteTracker!.continuances.length < 2) { + if (_sourceText.value == null || + _goldRouteTracker!.continuances.length < 2) { return; } - final sourceText = _sourceText!; + final sourceText = _sourceText.value!; + final goldContinuances = _goldRouteTracker!.continuances; String currentText = _choreographer.currentText + _goldRouteTracker!.continuances[0].text; @@ -199,21 +206,22 @@ class ITController { ); }, ); + if (_queue.isEmpty) break; if (res.isError) { _queue.last.completeError(res.asError!); break; } else { - final step = ITStep( + final step = ITStep.fromResponse( sourceText: sourceText, currentText: currentText, responseModel: res.result!, - storedGoldContinuances: _goldRouteTracker!.continuances, + storedGoldContinuances: goldContinuances, ); _queue.last.complete(step); } - currentText += _goldRouteTracker!.continuances[i].text; + currentText += goldContinuances[i].text; } } } @@ -259,7 +267,9 @@ class ITStep { late List continuances; late bool isFinal; - ITStep({ + ITStep({this.continuances = const [], this.isFinal = false}); + + factory ITStep.fromResponse({ required String sourceText, required String currentText, required ITResponseModel responseModel, @@ -269,8 +279,8 @@ class ITStep { storedGoldContinuances ?? responseModel.goldContinuances ?? []; final goldTracker = GoldRouteTracker(gold, sourceText); - isFinal = responseModel.isFinal; - + final isFinal = responseModel.isFinal; + List continuances; if (responseModel.continuances.isEmpty) { continuances = []; } else { @@ -298,5 +308,20 @@ class ITStep { continuances = List.from(responseModel.continuances); } } + + return ITStep( + continuances: continuances, + isFinal: isFinal, + ); + } + + ITStep copyWith({ + List? continuances, + bool? isFinal, + }) { + return ITStep( + continuances: continuances ?? this.continuances, + isFinal: isFinal ?? this.isFinal, + ); } } diff --git a/lib/pangea/choreographer/controllers/pangea_text_controller.dart b/lib/pangea/choreographer/controllers/pangea_text_controller.dart index 57ec593c1..7cdb479da 100644 --- a/lib/pangea/choreographer/controllers/pangea_text_controller.dart +++ b/lib/pangea/choreographer/controllers/pangea_text_controller.dart @@ -94,7 +94,7 @@ class PangeaTextController extends TextEditingController { final SubscriptionStatus canSendStatus = choreographer .pangeaController.subscriptionController.subscriptionStatus; if (canSendStatus == SubscriptionStatus.shouldShowPaywall && - !choreographer.isFetching && + !choreographer.isFetching.value && text.isNotEmpty) { return TextSpan( text: text, @@ -214,7 +214,7 @@ class PangeaTextController extends TextEditingController { spans.add(TextSpan(text: text, style: defaultStyle)); } - final openMatch = choreographer.openIGCMatch?.updatedMatch.match; + final openMatch = choreographer.openMatch?.updatedMatch.match; final style = _textStyle( match.updatedMatch, defaultStyle, diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 137b40d8c..4d5204324 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:developer'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -10,7 +8,6 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.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/extensions/choreographer_ui_extension.dart'; import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart'; @@ -34,223 +31,49 @@ class ITBar extends StatefulWidget { } class ITBarState extends State with SingleTickerProviderStateMixin { - bool showedClickInstruction = false; late AnimationController _controller; late Animation _animation; - bool wasOpen = false; + final TextEditingController _sourceTextController = TextEditingController(); + + Timer? _successTimer; @override void initState() { super.initState(); - // Rebuild the widget each time there's an update from choreo. - widget.choreographer.addListener(() { - if (widget.choreographer.isITOpen != wasOpen) { - widget.choreographer.isITOpen - ? _controller.forward() - : _controller.reverse(); - } - wasOpen = widget.choreographer.isITOpen; - setState(() {}); - }); - - wasOpen = widget.choreographer.isITOpen; - _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); - - // Start in the correct state - widget.choreographer.isITOpen - ? _controller.forward() - : _controller.reverse(); + _open.value ? _controller.forward() : _controller.reverse(); + _open.addListener(() { + final nextText = _sourceText.value ?? widget.choreographer.currentText; + if (_sourceTextController.text != nextText) { + _sourceTextController.text = nextText; + } + _open.value ? _controller.forward() : _controller.reverse(); + }); } - bool get showITInstructionsTooltip { - final toggledOff = InstructionsEnum.clickBestOption.isToggledOff; - if (!toggledOff) { - setState(() => showedClickInstruction = true); - } - return !toggledOff; - } - - bool get showTranslationsChoicesTooltip { - return !showedClickInstruction && - !showITInstructionsTooltip && - !widget.choreographer.isFetching && - !widget.choreographer.isEditingSourceText && - !widget.choreographer.isITDone && - widget.choreographer.itStepContinuances?.isNotEmpty == true; - } - - final double iconDimension = 36; - final double iconSize = 20; - @override - Widget build(BuildContext context) { - return SizeTransition( - sizeFactor: _animation, - axis: Axis.vertical, - axisAlignment: -1.0, - child: CompositedTransformTarget( - link: widget.choreographer.itBarLinkAndKey.link, - child: Column( - spacing: 8.0, - children: [ - if (showITInstructionsTooltip) - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.clickBestOption, - animate: false, - ), - if (showTranslationsChoicesTooltip) - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.translationChoices, - animate: false, - ), - Container( - key: widget.choreographer.itBarLinkAndKey.key, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), - topRight: Radius.circular(24), - ), - color: Theme.of(context).colorScheme.surfaceContainer, - ), - padding: const EdgeInsets.all(3), - child: SingleChildScrollView( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.choreographer.isEditingSourceText) - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 10, - top: 10, - ), - child: TextField( - controller: TextEditingController( - text: widget.choreographer.sourceText, - ), - autofocus: true, - enableSuggestions: false, - maxLines: null, - textInputAction: TextInputAction.send, - onSubmitted: - widget.choreographer.submitSourceTextEdits, - obscureText: false, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - ), - ), - ), - if (!widget.choreographer.isEditingSourceText && - widget.choreographer.sourceText != null) - SizedBox( - width: iconDimension, - height: iconDimension, - child: IconButton( - iconSize: iconSize, - color: Theme.of(context).colorScheme.primary, - onPressed: () => widget.choreographer - .setEditingSourceText(true), - icon: const Icon(Icons.edit_outlined), - // iconSize: 20, - ), - ), - if (!widget.choreographer.isEditingSourceText) - SizedBox( - width: iconDimension, - height: iconDimension, - child: IconButton( - iconSize: iconSize, - color: Theme.of(context).colorScheme.primary, - icon: const Icon(Icons.settings_outlined), - onPressed: () => showDialog( - context: context, - builder: (c) => const SettingsLearning(), - barrierDismissible: false, - ), - ), - ), - SizedBox( - width: iconDimension, - height: iconDimension, - child: IconButton( - iconSize: iconSize, - color: Theme.of(context).colorScheme.primary, - icon: const Icon(Icons.close_outlined), - onPressed: () { - widget.choreographer.isEditingSourceText - ? widget.choreographer - .setEditingSourceText(false) - : widget.choreographer.closeIT(); - }, - ), - ), - ], - ), - if (!widget.choreographer.isEditingSourceText) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: !widget.choreographer.isITOpen - ? const SizedBox() - : widget.choreographer.sourceText != null - ? Text( - widget.choreographer.sourceText!, - textAlign: TextAlign.center, - ) - : const LinearProgressIndicator(), - ), - const SizedBox(height: 8.0), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - constraints: const BoxConstraints(minHeight: 80), - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: Center( - child: widget.choreographer.errorService.isError - ? ITError(choreographer: widget.choreographer) - : widget.choreographer.isITDone - ? const SizedBox() - : ITChoices( - choreographer: widget.choreographer, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); + void dispose() { + _controller.dispose(); + _sourceTextController.dispose(); + _successTimer?.cancel(); + super.dispose(); } -} -class ITChoices extends StatelessWidget { - final Choreographer choreographer; - const ITChoices({ - super.key, - required this.choreographer, - }); + ValueNotifier get _sourceText => widget.choreographer.sourceText; + ValueNotifier get _open => widget.choreographer.itController.open; - void showCard( - BuildContext context, + void _showFeedbackCard( int index, [ Color? borderColor, String? choiceFeedback, ]) { - if (choreographer.itStepContinuances == null) { + final currentStep = widget.choreographer.itController.currentITStep.value; + if (currentStep == null) { ErrorHandler.logError( m: "currentITStep is null in showCard", s: StackTrace.current, @@ -261,45 +84,40 @@ class ITChoices extends StatelessWidget { return; } - final text = choreographer.itStepContinuances![index].text; - choreographer.chatController.inputFocus.unfocus(); + final text = currentStep.continuances[index].text; MatrixState.pAnyState.closeOverlay("it_feedback_card"); OverlayUtil.showPositionedCard( context: context, cardToShow: choiceFeedback == null ? WordDataCard( word: text, - wordLang: choreographer.l2LangCode!, - fullText: choreographer.sourceText ?? choreographer.currentText, - fullTextLang: choreographer.sourceText != null - ? choreographer.l1LangCode! - : choreographer.l2LangCode!, - choiceFeedback: choiceFeedback, + wordLang: widget.choreographer.l2LangCode!, + fullText: _sourceText.value ?? widget.choreographer.currentText, + fullTextLang: widget.choreographer.l1LangCode!, ) : ITFeedbackCard( - req: FullTextTranslationRequestModel( + FullTextTranslationRequestModel( text: text, - tgtLang: choreographer.l2LangCode!, - userL1: choreographer.l1LangCode!, - userL2: choreographer.l2LangCode!, + tgtLang: widget.choreographer.l1LangCode!, + userL1: widget.choreographer.l1LangCode!, + userL2: widget.choreographer.l2LangCode!, ), - choiceFeedback: choiceFeedback, ), maxHeight: 300, maxWidth: 300, borderColor: borderColor, - transformTargetId: choreographer.itBarTransformTargetKey, + transformTargetId: widget.choreographer.itBarTransformTargetKey, isScrollable: choiceFeedback == null, overlayKey: "it_feedback_card", ignorePointer: true, ); } - void selectContinuance(int index, BuildContext context) { + void _selectContinuance(int index) { MatrixState.pAnyState.closeOverlay("it_feedback_card"); Continuance continuance; try { - continuance = choreographer.onSelectContinuance(index); + continuance = widget.choreographer.onSelectContinuance(index); } catch (e, s) { ErrorHandler.logError( e: e, @@ -309,33 +127,15 @@ class ITChoices extends StatelessWidget { "index": index, }, ); - choreographer.closeIT(); + widget.choreographer.closeIT(); return; } if (continuance.level == 1) { - Future.delayed( - const Duration(milliseconds: 500), - () { - try { - choreographer.onAcceptContinuance(index); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - level: SentryLevel.warning, - data: { - "index": index, - }, - ); - choreographer.closeIT(); - return; - } - }, - ); + // CTODO doesn't always go to next continuance + _onCorrectSelection(index); } else { - showCard( - context, + _showFeedbackCard( index, continuance.level == 2 ? ChoreoConstants.yellow : ChoreoConstants.red, continuance.feedbackText(context), @@ -343,81 +143,254 @@ class ITChoices extends StatelessWidget { } } - @override - Widget build(BuildContext context) { - try { - if (choreographer.isEditingSourceText) { - return const SizedBox(); + void _onCorrectSelection(int index) { + _successTimer?.cancel(); + _successTimer = Timer(const Duration(milliseconds: 500), () { + if (!mounted) return; + try { + widget.choreographer.onAcceptContinuance(index); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + level: SentryLevel.warning, + data: { + "index": index, + }, + ); + widget.choreographer.closeIT(); } - if (choreographer.itStepContinuances == null) { - return choreographer.isITOpen - ? CircularProgressIndicator( - strokeWidth: 2.0, - color: Theme.of(context).colorScheme.primary, - ) - : const SizedBox(); - } - return ChoicesArray( - id: Object.hashAll(choreographer.itStepContinuances!).toString(), - isLoading: choreographer.isFetching || - choreographer.itStepContinuances == null, - choices: choreographer.itStepContinuances!.map((e) { - debugPrint("WAS CLICKED: ${e.wasClicked}"); - try { - return Choice( - text: e.text.trim(), - color: e.color, - isGold: e.description == "best", - ); - } catch (e) { - debugger(when: kDebugMode); - return Choice(text: "error", color: Colors.red); - } - }).toList(), - onPressed: (value, index) => selectContinuance(index, context), - onLongPress: (value, index) => showCard(context, index), - selectedChoiceIndex: null, - langCode: - choreographer.pangeaController.languageController.activeL2Code(), - ); - } catch (e) { - debugger(when: kDebugMode); - return const SizedBox(); - } + }); } -} - -class ITError extends StatelessWidget { - final Choreographer choreographer; - const ITError({ - super.key, - required this.choreographer, - }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - ErrorIndicator( - message: L10n.of(context).translationError, - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.error, + return AnimatedBuilder( + animation: _animation, + builder: (context, child) => SizeTransition( + sizeFactor: _animation, + axisAlignment: -1.0, + child: child, + ), + child: CompositedTransformTarget( + link: widget.choreographer.itBarLinkAndKey.link, + child: Column( + children: [ + if (!InstructionsEnum.clickBestOption.isToggledOff) ...[ + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.clickBestOption, + animate: false, + ), + const SizedBox(height: 8.0), + ], + Container( + key: widget.choreographer.itBarLinkAndKey.key, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + padding: const EdgeInsets.all(12.0), + child: Column( + spacing: 12.0, + children: [ + _ITBarHeader( + onClose: widget.choreographer.closeIT, + setEditing: widget.choreographer.itController.setEditing, + editing: widget.choreographer.itController.editing, + sourceTextController: _sourceTextController, + sourceText: _sourceText, + onSubmitEdits: widget.choreographer.submitSourceTextEdits, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + constraints: const BoxConstraints(minHeight: 80), + child: Center( + child: widget.choreographer.errorService.isError + ? Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + ErrorIndicator( + message: L10n.of(context).translationError, + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.error, + ), + ), + IconButton( + onPressed: widget.choreographer.closeIT, + icon: const Icon( + Icons.close, + size: 20, + ), + ), + ], + ) + : ValueListenableBuilder( + valueListenable: widget + .choreographer.itController.currentITStep, + builder: (context, step, __) { + return step == null + ? CircularProgressIndicator( + strokeWidth: 2.0, + color: Theme.of(context) + .colorScheme + .primary, + ) + : _ITChoices( + continuances: step.continuances, + onPressed: _selectContinuance, + onLongPressed: _showFeedbackCard, + ); + }, + ), + ), + ), + ], + ), ), - ), - IconButton( - onPressed: choreographer.closeIT, - icon: const Icon( - Icons.close, - size: 20, - ), - ), - ], + ], + ), ), ); } } + +class _ITBarHeader extends StatelessWidget { + final VoidCallback onClose; + final Function(String) onSubmitEdits; + final Function(bool) setEditing; + + final ValueNotifier editing; + final TextEditingController sourceTextController; + final ValueNotifier sourceText; + + const _ITBarHeader({ + required this.onClose, + required this.setEditing, + required this.editing, + required this.onSubmitEdits, + required this.sourceTextController, + required this.sourceText, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, __) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: isEditing + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Row( + spacing: 12.0, + children: [ + Expanded( + child: TextField( + controller: sourceTextController, + autofocus: true, + enableSuggestions: false, + maxLines: null, + textInputAction: TextInputAction.send, + onSubmitted: onSubmitEdits, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + ), + ), + IconButton( + color: Theme.of(context).colorScheme.primary, + icon: const Icon(Icons.close_outlined), + onPressed: () => setEditing(false), + ), + ], + ), + secondChild: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + color: Theme.of(context).colorScheme.primary, + onPressed: () => setEditing(true), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + color: Theme.of(context).colorScheme.primary, + icon: const Icon(Icons.settings_outlined), + onPressed: () => showDialog( + context: context, + builder: (c) => const SettingsLearning(), + barrierDismissible: false, + ), + ), + IconButton( + color: Theme.of(context).colorScheme.primary, + icon: const Icon(Icons.close_outlined), + onPressed: onClose, + ), + ], + ), + ), + isEditing + ? const SizedBox(height: 24.0) + : ValueListenableBuilder( + valueListenable: sourceText, + builder: (context, text, __) { + return Container( + padding: const EdgeInsets.only(top: 8.0), + constraints: const BoxConstraints(minHeight: 24.0), + child: sourceText.value != null + ? Text( + sourceText.value!, + textAlign: TextAlign.center, + ) + : const SizedBox(), + ); + }, + ), + ], + ); + }, + ); + } +} + +class _ITChoices extends StatelessWidget { + final List continuances; + final Function(int) onPressed; + final Function(int) onLongPressed; + + const _ITChoices({ + required this.continuances, + required this.onPressed, + required this.onLongPressed, + }); + + @override + Widget build(BuildContext context) { + return ChoicesArray( + id: Object.hashAll(continuances).toString(), + isLoading: false, + choices: [ + ...continuances.map( + (e) => Choice( + text: e.text.trim(), + color: e.color, + isGold: e.description == "best", + ), + ), + ], + onPressed: (value, index) => onPressed(index), + onLongPress: (value, index) => onLongPressed(index), + selectedChoiceIndex: null, + langCode: MatrixState.pangeaController.languageController.activeL2Code(), + ); + } +}