From 760d8a527a1e203e5ae74d9e456377aba21e7083 Mon Sep 17 00:00:00 2001 From: lrorpilla Date: Wed, 29 Mar 2023 11:26:41 +1000 Subject: [PATCH] ChatGPT support --- yuuna/lib/i18n/strings.g.dart | 18 +- yuuna/lib/i18n/strings.i18n.json | 7 + yuuna/lib/media.dart | 1 + yuuna/lib/pages.dart | 2 + .../media/sources/reader_chatgpt_source.dart | 213 +++++++ yuuna/lib/src/models/app_model.dart | 1 + .../pages/implementations/creator_page.dart | 1 - .../reader_chatgpt_login_page.dart | 61 ++ .../implementations/reader_chatgpt_page.dart | 544 ++++++++++++++++++ .../reader_websocket_page.dart | 3 +- .../recursive_dictionary_page.dart | 1 - .../jidoujisho_selectable_text.dart | 4 +- yuuna/pubspec.lock | 13 +- yuuna/pubspec.yaml | 6 +- 14 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 yuuna/lib/src/media/sources/reader_chatgpt_source.dart create mode 100644 yuuna/lib/src/pages/implementations/reader_chatgpt_login_page.dart create mode 100644 yuuna/lib/src/pages/implementations/reader_chatgpt_page.dart diff --git a/yuuna/lib/i18n/strings.g.dart b/yuuna/lib/i18n/strings.g.dart index 6c19a72a7..280a500cd 100644 --- a/yuuna/lib/i18n/strings.g.dart +++ b/yuuna/lib/i18n/strings.g.dart @@ -1,9 +1,9 @@ /// Generated file. Do not edit. /// /// Locales: 1 -/// Strings: 309 +/// Strings: 316 /// -/// Built on 2023-03-12 at 04:12 UTC +/// Built on 2023-03-28 at 13:06 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -456,6 +456,13 @@ class _StringsEn implements BaseTranslations { String get no_text_in_clipboard => 'No text to display'; String file_downloaded({required Object name}) => 'File downloaded: ${name}'; String get change_sort_order => 'Change Sort Order'; + String get login => 'Login'; + String get access_token_missing_expired => 'Access token missing or expired'; + String get send => 'Send'; + String get no_messages => 'Enter text to chat'; + String get enter_message => 'Enter message...'; + String get clear_message_title => 'Clear Messages'; + String get clear_message_description => 'This will clear all messages. Are you sure?'; late final _StringsViewRepliesEn view_replies = _StringsViewRepliesEn._(_root); } @@ -785,6 +792,13 @@ extension on _StringsEn { case 'no_text_in_clipboard': return 'No text to display'; case 'file_downloaded': return ({required Object name}) => 'File downloaded: ${name}'; case 'change_sort_order': return 'Change Sort Order'; + case 'login': return 'Login'; + case 'access_token_missing_expired': return 'Access token missing or expired'; + case 'send': return 'Send'; + case 'no_messages': return 'Enter text to chat'; + case 'enter_message': return 'Enter message...'; + case 'clear_message_title': return 'Clear Messages'; + case 'clear_message_description': return 'This will clear all messages. Are you sure?'; case 'view_replies.reply': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'SHOW ${n} REPLY', other: 'SHOW ${n} REPLIES', diff --git a/yuuna/lib/i18n/strings.i18n.json b/yuuna/lib/i18n/strings.i18n.json index 2f71a7b82..d60a33536 100644 --- a/yuuna/lib/i18n/strings.i18n.json +++ b/yuuna/lib/i18n/strings.i18n.json @@ -331,6 +331,13 @@ "no_text_in_clipboard": "No text to display", "file_downloaded": "File downloaded: $name", "change_sort_order": "Change Sort Order", + "login": "Login", + "access_token_missing_expired": "Login token missing or expired", + "send": "Send", + "no_messages": "Start a chat", + "enter_message": "Enter message...", + "clear_message_title": "Clear Messages", + "clear_message_description": "This will clear all messages and start a new chat. Are you sure?", "view_replies": { "reply": { "one": "SHOW $n REPLY", diff --git a/yuuna/lib/media.dart b/yuuna/lib/media.dart index c8c6e545a..9ab8a8e07 100644 --- a/yuuna/lib/media.dart +++ b/yuuna/lib/media.dart @@ -17,5 +17,6 @@ export 'src/media/sources/player_youtube_source.dart'; export 'src/media/sources/reader_ttu_source.dart'; export 'src/media/sources/reader_lyrics_source.dart'; export 'src/media/sources/reader_clipboard_source.dart'; +export 'src/media/sources/reader_chatgpt_source.dart'; export 'src/media/sources/reader_websocket_source.dart'; export 'src/media/sources/viewer_camera_source.dart'; diff --git a/yuuna/lib/pages.dart b/yuuna/lib/pages.dart index 46d3ce401..5bb3fe538 100644 --- a/yuuna/lib/pages.dart +++ b/yuuna/lib/pages.dart @@ -51,6 +51,8 @@ export 'src/pages/implementations/dictionary_settings_dialog_page.dart'; export 'src/pages/implementations/reader_clipboard_page.dart'; export 'src/pages/implementations/pip_page.dart'; export 'src/pages/implementations/audio_recorder_page.dart'; +export 'src/pages/implementations/reader_chatgpt_page.dart'; +export 'src/pages/implementations/reader_chatgpt_login_page.dart'; export 'src/pages/base_page.dart'; export 'src/pages/base_history_page.dart'; diff --git a/yuuna/lib/src/media/sources/reader_chatgpt_source.dart b/yuuna/lib/src/media/sources/reader_chatgpt_source.dart new file mode 100644 index 000000000..c813d6804 --- /dev/null +++ b/yuuna/lib/src/media/sources/reader_chatgpt_source.dart @@ -0,0 +1,213 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_chatgpt_api/flutter_chatgpt_api.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_floating_search_bar/material_floating_search_bar.dart'; +import 'package:spaces/spaces.dart'; +import 'package:yuuna/media.dart'; +import 'package:yuuna/models.dart'; +import 'package:yuuna/pages.dart'; +import 'package:yuuna/utils.dart'; + +// ignore: implementation_imports +import 'package:flutter_chatgpt_api/src/models/chat_response.model.dart'; + +/// A global [Provider] for getting necessary cookies. +final accessCookieProvider = FutureProvider((ref) { + return CookieManager.instance().getCookie( + url: Uri.parse('https://chat.openai.com/'), + name: '__Secure-next-auth.session-token', + ); +}); + +/// A global [Provider] for getting necessary cookies. +final clearanceCookieProvider = FutureProvider((ref) { + return CookieManager.instance().getCookie( + url: Uri.parse('https://chat.openai.com/'), + name: 'cf_clearance', + ); +}); + +/// A media source that allows the user to paste and select text. +class ReaderChatgptSource extends ReaderMediaSource { + /// Define this media source. + ReaderChatgptSource._privateConstructor() + : super( + uniqueKey: 'reader_chatgpt', + sourceName: 'ChatGPT', + description: + 'Allows the user to login to OpenAI and access its unofficial REST API.', + icon: Icons.chat_outlined, + implementsSearch: false, + implementsHistory: false, + ); + + /// Get the singleton instance of this media type. + static ReaderChatgptSource get instance => _instance; + + static final ReaderChatgptSource _instance = + ReaderChatgptSource._privateConstructor(); + + /// List of chat messages sent to and received from ChatGPT. + List messages = []; + + /// Gets the last response from ChatGPT. + ChatResponse? lastResponse; + + /// Access token used for sending messages. + String? messageAccessToken; + + /// Used for preparing the actual access token used for sending messages. + /// This is different from the access token persisted in the cookies. + Future prepareMessageAccessToken() async { + bool webViewBusy = true; + + if (messageAccessToken == null) { + HeadlessInAppWebView webView = HeadlessInAppWebView( + initialOptions: InAppWebViewGroupOptions( + crossPlatform: InAppWebViewOptions( + userAgent: + 'Mozilla 5.0 (Linux; U; Android 13) Chrome/104.0.5112.99', + ), + ), + initialUrlRequest: URLRequest( + url: Uri.parse('https://chat.openai.com/api/auth/session'), + ), + onLoadStop: (controller, uri) async { + messageAccessToken = await controller.evaluateJavascript( + source: 'JSON.parse(document.body.textContent).accessToken;'); + + webViewBusy = false; + }); + + await webView.run(); + + while (webViewBusy) { + await Future.delayed(const Duration(milliseconds: 100), () {}); + } + + await webView.dispose(); + } + } + + @override + Future onSearchBarTap({ + required BuildContext context, + required WidgetRef ref, + required AppModel appModel, + }) async {} + + @override + BaseSourcePage buildLaunchPage({ + MediaItem? item, + }) { + throw UnsupportedError('ChatGPT source does not launch any page'); + } + + @override + List getActions({ + required BuildContext context, + required WidgetRef ref, + required AppModel appModel, + }) { + return [ + buildClearButton( + context: context, + ref: ref, + appModel: appModel, + ), + buildLoginButton(context: context, ref: ref, appModel: appModel), + ]; + } + + /// Menu bar action. + Widget buildLoginButton( + {required BuildContext context, + required WidgetRef ref, + required AppModel appModel}) { + return FloatingSearchBarAction( + child: JidoujishoIconButton( + size: Theme.of(context).textTheme.titleLarge?.fontSize, + tooltip: t.login, + icon: Icons.login, + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ReaderChatgptLoginPage()), + ); + + ref.refresh(accessCookieProvider); + ref.refresh(clearanceCookieProvider); + }, + ), + ); + } + + /// Menu bar action. + Widget buildClearButton( + {required BuildContext context, + required WidgetRef ref, + required AppModel appModel}) { + return FloatingSearchBarAction( + child: JidoujishoIconButton( + size: Theme.of(context).textTheme.titleLarge?.fontSize, + tooltip: t.clear_text_title, + icon: Icons.clear_all, + onTap: () { + showClearPrompt( + appModel: appModel, + context: context, + ref: ref, + ); + }, + ), + ); + } + + /// Shows when the clear button is pressed. + void showClearPrompt( + {required BuildContext context, + required WidgetRef ref, + required AppModel appModel}) async { + Widget alertDialog = AlertDialog( + contentPadding: MediaQuery.of(context).orientation == Orientation.portrait + ? Spacing.of(context).insets.exceptBottom.big + : Spacing.of(context).insets.exceptBottom.normal, + title: Text(t.clear_message_title), + content: Text(t.clear_message_description), + actions: [ + TextButton( + child: Text( + t.dialog_clear, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + onPressed: () async { + messages = []; + lastResponse = null; + appModel.refresh(); + + Navigator.pop(context); + }, + ), + TextButton( + child: Text(t.dialog_cancel), + onPressed: () => Navigator.pop(context), + ), + ], + ); + + await showDialog( + context: context, + builder: (context) => alertDialog, + ); + } + + @override + BasePage buildHistoryPage({MediaItem? item}) { + return const ReaderChatgptPage(); + } +} diff --git a/yuuna/lib/src/models/app_model.dart b/yuuna/lib/src/models/app_model.dart index 99b923ad3..36972edbc 100644 --- a/yuuna/lib/src/models/app_model.dart +++ b/yuuna/lib/src/models/app_model.dart @@ -683,6 +683,7 @@ class AppModel with ChangeNotifier { ReaderMediaType.instance: [ ReaderTtuSource.instance, ReaderLyricsSource.instance, + ReaderChatgptSource.instance, ReaderClipboardSource.instance, ReaderWebsocketSource.instance, ], diff --git a/yuuna/lib/src/pages/implementations/creator_page.dart b/yuuna/lib/src/pages/implementations/creator_page.dart index 641bbc290..7297afa5b 100644 --- a/yuuna/lib/src/pages/implementations/creator_page.dart +++ b/yuuna/lib/src/pages/implementations/creator_page.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_exit_app/flutter_exit_app.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:spaces/spaces.dart'; import 'package:subtitle/subtitle.dart'; diff --git a/yuuna/lib/src/pages/implementations/reader_chatgpt_login_page.dart b/yuuna/lib/src/pages/implementations/reader_chatgpt_login_page.dart new file mode 100644 index 000000000..180509b5f --- /dev/null +++ b/yuuna/lib/src/pages/implementations/reader_chatgpt_login_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:yuuna/media.dart'; +import 'package:yuuna/pages.dart'; +import 'package:yuuna/utils.dart'; + +/// A page for [ReaderChatgptSource] which shows the content of the current +/// clipboard as selectable text. +class ReaderChatgptLoginPage extends BasePage { + /// Create an instance of this tab page. + const ReaderChatgptLoginPage({ + super.key, + }); + + @override + BasePageState createState() => _ReaderChatgptLoginPageState(); +} + +/// A base class for providing all tabs in the main menu. In large part, this +/// was implemented to define shortcuts for common lengthy methods across UI +/// code. +class _ReaderChatgptLoginPageState extends BasePageState { + ReaderChatgptSource get source => ReaderChatgptSource.instance; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(t.login), + leading: const BackButton(), + ), + body: InAppWebView( + initialOptions: InAppWebViewGroupOptions( + crossPlatform: InAppWebViewOptions( + userAgent: + 'Mozilla 5.0 (Linux; U; Android 13) Chrome/104.0.5112.99', + ), + ), + initialUrlRequest: URLRequest( + url: Uri.parse('https://chat.openai.com/auth/login'), + ), + onLoadStop: (controller, uri) async { + Cookie? accessCookie = await CookieManager.instance().getCookie( + url: Uri.parse('https://chat.openai.com/'), + name: '__Secure-next-auth.session-token', + ); + Cookie? clearanceCookie = await CookieManager.instance().getCookie( + url: Uri.parse('https://chat.openai.com/'), + name: 'cf_clearance', + ); + + if (accessCookie != null && clearanceCookie != null) { + if (mounted) { + Navigator.popUntil(context, (route) => route.isFirst); + } + } + }, + ), + ); + } +} diff --git a/yuuna/lib/src/pages/implementations/reader_chatgpt_page.dart b/yuuna/lib/src/pages/implementations/reader_chatgpt_page.dart new file mode 100644 index 000000000..e28bbf382 --- /dev/null +++ b/yuuna/lib/src/pages/implementations/reader_chatgpt_page.dart @@ -0,0 +1,544 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_chatgpt_api/flutter_chatgpt_api.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:progress_indicators/progress_indicators.dart'; +import 'package:spaces/spaces.dart'; +import 'package:yuuna/creator.dart'; +import 'package:yuuna/media.dart'; +import 'package:yuuna/pages.dart'; +import 'package:yuuna/utils.dart'; + +/// A page for [ReaderChatgptSource] which allows the user to interact with the +/// ChatGPT service. +class ReaderChatgptPage extends BaseSourcePage { + /// Create an instance of this tab page. + const ReaderChatgptPage({ + super.item, + super.key, + }); + + @override + BaseSourcePageState createState() => _ReaderChatgptPageState(); +} + +/// A base class for providing all tabs in the main menu. In large part, this +/// was implemented to define shortcuts for common lengthy methods across UI +/// code. +class _ReaderChatgptPageState extends BaseSourcePageState { + ReaderChatgptSource get source => ReaderChatgptSource.instance; + + final ScrollController _scrollController = + ReaderMediaType.instance.scrollController; + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + bool _isLoading = false; + + ChatGPTApi? _api; + + final ValueNotifier _progressNotifier = ValueNotifier(''); + + @override + Widget build(BuildContext context) { + return ref.watch(accessCookieProvider).when( + data: (accessCookie) { + return ref.watch(clearanceCookieProvider).when( + data: (clearanceCookie) { + if (accessCookie == null || clearanceCookie == null) { + return buildNoAccessToken(); + } else { + _api ??= ChatGPTApi( + sessionToken: accessCookie.value, + clearanceToken: clearanceCookie.value, + ); + + source.prepareMessageAccessToken(); + if (_scrollController.hasClients) { + _scrollController + .jumpTo(_scrollController.position.maxScrollExtent); + } + + return Column( + children: [ + Expanded( + child: Stack( + children: [ + if (source.messages.isEmpty) + buildEmpty() + else + GestureDetector( + onTap: clearDictionaryResult, + child: buildMessageBuilder(), + ), + Column( + children: [ + const Space.extraBig(), + Expanded( + child: buildDictionary(), + ), + ], + ), + ], + ), + ), + buildTextField(), + SizedBox(height: getBottomInsets() * 0.85) + ], + ); + } + }, + error: (error, stack) => + buildError(error: error, stack: stack), + loading: buildLoading, + ); + }, + error: (error, stack) => buildError(error: error, stack: stack), + loading: buildLoading, + ); + } + + Widget buildEmpty() { + return Center( + child: JidoujishoPlaceholderMessage( + icon: Icons.chat_outlined, + message: t.no_messages, + ), + ); + } + + double getBottomInsets() { + if (MediaQuery.of(context).viewInsets.bottom > + MediaQuery.of(context).viewPadding.bottom) { + return MediaQuery.of(context).viewInsets.bottom - + MediaQuery.of(context).viewPadding.bottom; + } + return 0; + } + + Widget buildTextField() { + return Padding( + padding: Spacing.of(context).insets.all.normal, + child: TextField( + focusNode: _focusNode, + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + readOnly: _isLoading, + decoration: InputDecoration( + hintText: t.enter_message, + suffixIcon: _isLoading + ? SizedBox( + height: Spacing.of(context).spaces.extraBig, + width: Spacing.of(context).spaces.extraBig, + child: Padding( + padding: Spacing.of(context).insets.all.semiBig, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary), + ), + ), + ) + : JidoujishoIconButton( + icon: Icons.send, + tooltip: t.send, + onTap: () => onSubmitted(_controller.text), + ), + ), + onTap: clearDictionaryResult, + onSubmitted: onSubmitted, + ), + ); + } + + void onSubmitted(String input) async { + String text = input.trim(); + if (text.isEmpty) { + return; + } + + _controller.clear(); + _progressNotifier.value = ''; + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + setState(() { + source.messages.add( + ChatMessage( + text: text, + chatMessageType: ChatMessageType.user, + ), + ); + _isLoading = true; + }); + + await source.prepareMessageAccessToken(); + + try { + final response = await _api!.sendMessage( + accessToken: source.messageAccessToken!, + message: text, + onProgress: (progress) { + _progressNotifier.value = progress.message; + if (_scrollController.hasClients) { + _scrollController + .jumpTo(_scrollController.position.maxScrollExtent); + } + }, + conversationId: source.lastResponse?.conversationId, + parentMessageId: source.lastResponse?.messageId, + ); + + source.lastResponse = response; + + source.messages.add( + ChatMessage( + text: response.message.trim(), + chatMessageType: ChatMessageType.bot, + ), + ); + } catch (e) { + if (source.messageAccessToken == null) { + await CookieManager.instance().deleteCookies( + url: Uri.parse('https://chat.openai.com/'), + domain: ref.read(accessCookieProvider).value?.domain, + path: ref.read(accessCookieProvider).value?.path ?? '/', + ); + await CookieManager.instance().deleteCookies( + url: Uri.parse('https://chat.openai.com/'), + domain: ref.read(clearanceCookieProvider).value?.domain, + path: ref.read(clearanceCookieProvider).value?.path ?? '/', + ); + + ref.refresh(accessCookieProvider); + ref.refresh(clearanceCookieProvider); + } + } finally { + SchedulerBinding.instance.addPostFrameCallback((_) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + _focusNode.requestFocus(); + }); + + setState(() { + _isLoading = false; + }); + } + } + + Widget buildNoAccessToken() { + return Center( + child: JidoujishoPlaceholderMessage( + icon: Icons.login, + message: t.access_token_missing_expired, + ), + ); + } + + Widget buildMessageBuilder() { + List messages = source.messages.toList(); + return RawScrollbar( + controller: _scrollController, + thumbVisibility: true, + thickness: 3, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics()), + controller: _scrollController, + itemCount: _isLoading ? messages.length + 1 : messages.length, + itemBuilder: (context, index) { + if (index == messages.length) { + return ValueListenableBuilder( + valueListenable: _progressNotifier, + builder: (context, value, child) { + return buildMessage( + isBot: true, + isLoading: true, + text: _progressNotifier.value, + ); + }, + ); + } + + ChatMessage message = messages[index]; + + return Padding( + padding: EdgeInsets.only(top: index == 0 ? 60 : 0), + child: buildMessage( + text: message.text, + isBot: message.chatMessageType == ChatMessageType.bot, + isLoading: false, + ), + ); + }, + ), + ); + } + + JidoujishoSelectableTextController? _lastTappedController; + + @override + void clearDictionaryResult() { + super.clearDictionaryResult(); + _lastTappedController?.clearSelection(); + source.clearCurrentSentence(); + } + + List getTextSpans({ + required JidoujishoSelectableTextController controller, + required String text, + }) { + List spans = []; + + text.runes.forEachIndexed((index, rune) { + String character = String.fromCharCode(rune); + spans.add( + TextSpan( + text: character, + style: const TextStyle(fontSize: 18), + recognizer: TapGestureRecognizer() + ..onTapDown = (details) async { + _lastTappedController?.clearSelection(); + _lastTappedController = controller; + + bool wholeWordCondition = controller.selection.start <= index && + controller.selection.end > index; + + if (wholeWordCondition && currentResult != null) { + clearDictionaryResult(); + return; + } + + source.setCurrentSentence(text); + + double x = details.globalPosition.dx; + double y = details.globalPosition.dy; + + late JidoujishoPopupPosition position; + if (MediaQuery.of(context).orientation == Orientation.portrait) { + if (y < MediaQuery.of(context).size.height / 2) { + position = JidoujishoPopupPosition.bottomHalf; + } else { + position = JidoujishoPopupPosition.topHalf; + } + } else { + if (x < MediaQuery.of(context).size.width / 2) { + position = JidoujishoPopupPosition.rightHalf; + } else { + position = JidoujishoPopupPosition.leftHalf; + } + } + + String searchTerm = + appModel.targetLanguage.getSearchTermFromIndex( + text: text, + index: index, + ); + + if (character.trim().isNotEmpty) { + bool isSpaceDelimited = + appModel.targetLanguage.isSpaceDelimited; + int whitespaceOffset = + searchTerm.length - searchTerm.trimLeft().length; + int offsetIndex = appModel.targetLanguage + .getStartingIndex(text: text, index: index) + + whitespaceOffset; + int length = appModel.targetLanguage + .textToWords(searchTerm) + .firstWhere((e) => e.trim().isNotEmpty) + .length; + + controller.setSelection( + offsetIndex, + offsetIndex + length, + ); + + searchDictionaryResult( + searchTerm: searchTerm, + position: position, + ).then((result) { + int length = isSpaceDelimited + ? appModel.targetLanguage + .textToWords(searchTerm) + .firstWhere((e) => e.trim().isNotEmpty) + .length + : max(1, currentResult?.bestLength ?? 0); + + controller.setSelection(offsetIndex, offsetIndex + length); + }); + } else { + clearDictionaryResult(); + } + + FocusScope.of(context).unfocus(); + }, + ), + ); + }); + + return spans; + } + + Widget buildMessage({ + required String text, + required bool isBot, + required bool isLoading, + }) { + final JidoujishoSelectableTextController controller = + JidoujishoSelectableTextController(); + + return Padding( + padding: EdgeInsets.only( + top: Spacing.of(context).spaces.extraSmall, + left: Spacing.of(context).spaces.normal, + right: Spacing.of(context).spaces.normal, + ), + child: Card( + color: isBot ? Colors.red.withOpacity(0.5) : null, + child: Padding( + padding: Spacing.of(context).insets.all.normal, + child: isLoading && text.isEmpty + ? JumpingDotsProgressIndicator( + fontSize: theme.textTheme.bodyMedium!.fontSize!, + color: Theme.of(context).appBarTheme.foregroundColor!, + ) + : Row( + children: [ + Expanded( + child: JidoujishoSelectableText.rich( + TextSpan(children: [ + ...getTextSpans( + controller: controller, + text: text, + ), + if (isLoading) + WidgetSpan( + child: SizedBox( + width: theme.textTheme.bodyMedium!.fontSize, + child: JumpingDotsProgressIndicator( + fontSize: + theme.textTheme.bodyMedium!.fontSize!, + color: Theme.of(context) + .appBarTheme + .foregroundColor!, + ), + ), + ), + ]), + selectionControls: JidoujishoTextSelectionControls( + searchAction: (selection) async { + source.setCurrentSentence(text); + await appModel.openRecursiveDictionarySearch( + searchTerm: selection, + killOnPop: false, + ); + source.clearCurrentSentence(); + }, + searchActionLabel: t.search, + stashAction: onContextStash, + stashActionLabel: t.stash, + creatorAction: (selection) async { + launchCreator(term: selection, sentence: text); + }, + creatorActionLabel: t.creator, + allowCopy: true, + allowSelectAll: false, + allowCut: true, + allowPaste: true, + ), + controller: controller, + style: const TextStyle(fontSize: 18), + ), + ), + buildTextSegmentButton(text), + buildCardCreatorButton(text), + ], + ), + ), + shape: const RoundedRectangleBorder(), + ), + ); + } + + Widget buildTextSegmentButton(String message) { + return Padding( + padding: Spacing.of(context).insets.onlyLeft.semiSmall, + child: JidoujishoIconButton( + busy: true, + shapeBorder: const RoundedRectangleBorder(), + backgroundColor: + Theme.of(context).appBarTheme.foregroundColor?.withOpacity(0.1), + size: Spacing.of(context).spaces.semiBig, + tooltip: t.text_segmentation, + icon: Icons.account_tree, + onTap: () async { + appModel.openTextSegmentationDialog( + sourceText: message, + onSearch: (selection, items) async { + source.setCurrentSentence(message); + await appModel.openRecursiveDictionarySearch( + searchTerm: selection, + killOnPop: false, + ); + source.clearCurrentSentence(); + }, + onSelect: (selection, items) { + appModel.openCreator( + creatorFieldValues: CreatorFieldValues( + textValues: { + TermField.instance: selection, + SentenceField.instance: message, + }, + ), + killOnPop: false, + ref: ref, + ); + Navigator.pop(context); + }, + ); + }, + ), + ); + } + + @override + void onContextSearch(String searchTerm, {String? sentence}) async {} + + Widget buildCardCreatorButton(String message) { + return Padding( + padding: Spacing.of(context).insets.onlyLeft.semiSmall, + child: JidoujishoIconButton( + busy: true, + shapeBorder: const RoundedRectangleBorder(), + backgroundColor: + Theme.of(context).appBarTheme.foregroundColor?.withOpacity(0.1), + size: Spacing.of(context).spaces.semiBig, + tooltip: t.card_creator, + icon: Icons.note_add, + onTap: () async { + launchCreator(term: '', sentence: message); + }, + ), + ); + } + + void launchCreator({required String term, required String sentence}) async { + await appModel.openCreator( + creatorFieldValues: CreatorFieldValues( + textValues: { + SentenceField.instance: sentence, + TermField.instance: term, + }, + ), + killOnPop: false, + ref: ref, + ); + } +} diff --git a/yuuna/lib/src/pages/implementations/reader_websocket_page.dart b/yuuna/lib/src/pages/implementations/reader_websocket_page.dart index acbabeeb5..51ffd91cd 100644 --- a/yuuna/lib/src/pages/implementations/reader_websocket_page.dart +++ b/yuuna/lib/src/pages/implementations/reader_websocket_page.dart @@ -25,7 +25,8 @@ class ReaderWebsocketPage extends BaseSourcePage { /// A base class for providing all tabs in the main menu. In large part, this /// was implemented to define shortcuts for common lengthy methods across UI /// code. -class _ReaderWebsocketPageState extends BaseSourcePageState { +class _ReaderWebsocketPageState + extends BaseSourcePageState { ReaderWebsocketSource get source => ReaderWebsocketSource.instance; @override diff --git a/yuuna/lib/src/pages/implementations/recursive_dictionary_page.dart b/yuuna/lib/src/pages/implementations/recursive_dictionary_page.dart index 975389cc5..3b1ec4963 100644 --- a/yuuna/lib/src/pages/implementations/recursive_dictionary_page.dart +++ b/yuuna/lib/src/pages/implementations/recursive_dictionary_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_exit_app/flutter_exit_app.dart'; import 'package:material_floating_search_bar/material_floating_search_bar.dart'; import 'package:spaces/spaces.dart'; import 'package:yuuna/creator.dart'; diff --git a/yuuna/lib/src/utils/components/jidoujisho_selectable_text.dart b/yuuna/lib/src/utils/components/jidoujisho_selectable_text.dart index 7a50a6443..5e86c954d 100644 --- a/yuuna/lib/src/utils/components/jidoujisho_selectable_text.dart +++ b/yuuna/lib/src/utils/components/jidoujisho_selectable_text.dart @@ -742,7 +742,7 @@ class _JidoujishoSelectableTextState extends State cupertinoTheme.primaryColor; selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); - cursorRadius ??= const Radius.circular(2.0); + cursorRadius ??= const Radius.circular(2); cursorOffset = Offset( iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); break; @@ -758,7 +758,7 @@ class _JidoujishoSelectableTextState extends State cupertinoTheme.primaryColor; selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); - cursorRadius ??= const Radius.circular(2.0); + cursorRadius ??= const Radius.circular(2); cursorOffset = Offset( iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); break; diff --git a/yuuna/pubspec.lock b/yuuna/pubspec.lock index 47638ef65..4f0b1048f 100644 --- a/yuuna/pubspec.lock +++ b/yuuna/pubspec.lock @@ -608,6 +608,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + flutter_chatgpt_api: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: b249c8cd078ebc9c62d957bc3b07cec8ba7328bf + url: "https://github.com/lrorpilla/flutter_chatgpt_api" + source: git + version: "1.1.0" flutter_colorpicker: dependency: "direct main" description: @@ -1808,10 +1817,10 @@ packages: dependency: transitive description: name: uuid - sha256: "2469694ad079893e3b434a627970c33f2fa5adc46dfe03c9617546969a9a8afc" + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" ve_dart: dependency: "direct main" description: diff --git a/yuuna/pubspec.yaml b/yuuna/pubspec.yaml index f33a7a231..6a5e3dd55 100644 --- a/yuuna/pubspec.yaml +++ b/yuuna/pubspec.yaml @@ -1,7 +1,7 @@ name: yuuna description: A full-featured immersion language learning suite for mobile. publish_to: 'none' -version: 2.4.5+46 +version: 2.5.0+47 environment: sdk: ">=2.17.0 <3.0.0" @@ -42,6 +42,10 @@ dependencies: flutter_archive: ^5.0.0 flutter_cache_manager: ^3.3.0 flutter_charset_detector: ^1.0.2 + flutter_chatgpt_api: + git: + url: https://github.com/lrorpilla/flutter_chatgpt_api + ref: master flutter_colorpicker: ^1.0.3 flutter_exit_app: ^1.0.5 flutter_fadein: ^2.0.0