diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a4058486b..211d34df2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -108,6 +108,19 @@ + + + + + + + + + + + ???? CFBundleURLTypes + + CFBundleURLSchemes + + pangea + + CFBundleURLName + com.talktolearn.chat + CFBundleTypeRole Editor @@ -113,5 +121,7 @@ io.flutter.embedded_views_preview + FlutterDeepLinkingEnabled + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 2b2a88dd1..91e1a0719 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -1,18 +1,16 @@ - - aps-environment - development - com.apple.developer.associated-domains - - applinks:example.com - - com.apple.security.application-groups - - - group.com.talktolearn.chat - - - - \ No newline at end of file + + aps-environment + development + com.apple.developer.associated-domains + + applinks:app.pangea.chat + + com.apple.security.application-groups + + group.com.talktolearn.chat + + + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index e4167ea7b..32340d99e 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -27,9 +27,10 @@ abstract class AppConfig { static const bool allowOtherHomeservers = true; static const bool enableRegistration = true; // #Pangea - static const double toolbarMaxHeight = 250.0; + static const double toolbarMaxHeight = 225.0; static const double toolbarMinHeight = 150.0; static const double toolbarMinWidth = 350.0; + static const double toolbarMenuHeight = 215.0; static const double defaultHeaderHeight = 56.0; static const double toolbarButtonsHeight = 50.0; static const double toolbarSpacing = 8.0; @@ -89,7 +90,7 @@ abstract class AppConfig { static String _privacyUrl = "https://www.pangeachat.com/privacy"; //Pangea# - static const Set defaultReactions = {'👍', '❤️', '😊'}; + static const Set defaultReactions = {'👍', '❤️', '😂', '😮', '😢'}; static String get privacyUrl => _privacyUrl; // #Pangea diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 7b44dc54d..5cd585a4b 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -32,9 +32,11 @@ import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; import 'package:fluffychat/pangea/activity_generator/activity_generator.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; -import 'package:fluffychat/pangea/activity_suggestions/suggestions_page.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page.dart'; +import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/common/widgets/pangea_side_view.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/find_your_people/find_your_people.dart'; -import 'package:fluffychat/pangea/find_your_people/find_your_people_side_view.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; import 'package:fluffychat/pangea/login/pages/login_or_signup_view.dart'; @@ -203,7 +205,8 @@ abstract class AppRoutes { // state.fullPath?.startsWith('/rooms/settings') == false FluffyThemes.isColumnMode(context) && state.fullPath?.startsWith('/rooms/settings') == false && - state.fullPath?.startsWith('/rooms/communities') == false + state.fullPath?.startsWith('/rooms/communities') == false && + state.fullPath?.startsWith('/rooms/analytics') == false // Pangea# ? TwoColumnLayout( mainView: ChatList( @@ -316,7 +319,7 @@ abstract class AppRoutes { state, FluffyThemes.isColumnMode(context) ? TwoColumnLayout( - mainView: const FindYourPeopleSideView(), + mainView: PangeaSideView(path: state.fullPath), sideView: child, dividerColor: Colors.transparent, ) @@ -332,37 +335,21 @@ abstract class AppRoutes { const FindYourPeople(), ), ), - ], - ), - GoRoute( - path: 'homepage', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SuggestionsPage(), - ), - routes: [ - ...newRoomRoutes, GoRoute( - path: '/planner', + path: 'analytics', + redirect: loggedOutRedirect, pageBuilder: (context, state) => defaultPageBuilder( context, state, - const ActivityPlannerPage(), - ), - redirect: loggedOutRedirect, - routes: [ - GoRoute( - path: '/generator', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const ActivityGenerator(), + AnalyticsPage( + selectedIndicator: ProgressIndicatorEnum.fromString( + state.uri.queryParameters['mode'] ?? 'vocab', ), + constructZoom: state.extra is ConstructIdentifier + ? state.extra as ConstructIdentifier + : null, ), - ], + ), ), ], ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 11d114939..019fcf6e9 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -4515,9 +4515,10 @@ "grammarCopyPUNCTTYPEperi": "Period", "grammarCopyREFLEXyes": "Reflexive", "grammarCopyTENSEimp": "Imperfect", - "grammarCopyVERBFORMsup": "SuApine", + "grammarCopyVERBFORMsup": "Supine", "grammarCopyVERBFORMadn": "Adnominal", "grammarCopyVERBFORMlng": "Long", + "grammarCopyVERBFORMshrt": "Short", "grammarCopyVERBTYPEcaus": "Causative Verb", "grammarCopyVOICEcau": "Causative", "grammarCopyVOICEdir": "Direct", @@ -4590,6 +4591,7 @@ "constructUseIncMDesc": "Incorrect in grammar activity", "constructUseIgnMDesc": "Ignored in grammar activity", "constructUseEmojiDesc": "Correct in emoji activity", + "constructUseCollected": "Collected in chat", "constructUseNanDesc": "Not applicable", "xpIntoLevel": "{currentXP} / {maxXP} XP", "@xpIntoLevel": { @@ -4631,9 +4633,9 @@ "meaningSectionHeader": "Meaning:", "formSectionHeader": "Forms used in chats:", "noEmojiSelectedTooltip": "No emoji selected", - "writingExercisesTooltip": "Writing practice", - "listeningExercisesTooltip": "Listening practice", - "readingExercisesTooltip": "Reading practice", + "writingExercisesTooltip": "Writing", + "listeningExercisesTooltip": "Listening", + "readingExercisesTooltip": "Reading", "meaningNotFound": "Meaning could not be found.", "formsNotFound": "Forms could not be found.", "chooseBaseForm": "Choose the base form", @@ -4733,7 +4735,7 @@ "activityPlannerTitle": "Activity Planner", "topicLabel": "Topic", "topicPlaceholder": "Choose a topic...", - "modeLabel": "Mode", + "modeLabel": "Activity type", "modePlaceholder": "Choose a mode...", "learningObjectiveLabel": "Learning Objective", "learningObjectivePlaceholder": "Choose a learning objective...", @@ -4747,12 +4749,9 @@ "video": "Video", "nan": "Not applicable", "activityPlannerOverviewInstructionsBody": "Choose a topic, mode, learning objective and generate an activity for the chat!", - "myBookmarkedActivities": "My Bookmarked Activities", - "noBookmarkedActivities": "No bookmarked activities", "activityTitle": "Activity Title", "addVocabulary": "Add vocabulary", "instructions": "Instructions", - "bookmark": "Bookmark this activity", "numberOfLearners": "Number of learners", "mustBeInteger": "Must be an integer e.g. 1, 2, 3, ...", "noLemmasFound": "There's no vocabulary with more than {xp} XP. Keep practicing!", @@ -4874,9 +4873,8 @@ "exploreMore": "Explore more", "randomize": "Randomize", "clear": "Clear", - "makeYourOwnActivity": "Make your own activity", + "makeYourOwnActivity": "Create your own activity", "featuredActivities": "Featured", - "yourBookmarks": "Bookmarked", "goToChat": "Go to chat", "save": "Save", "selectActivity": "Select activity", @@ -5002,6 +5000,7 @@ "canBeFoundViaKnock": "\u2022 request to join and admin approval", "anyoneCanJoin": "Anyone can join! However, admin can kick and ban whoever misbehaves. Those who are banned may not return!", "createYourSpace": "Create your space", + "youHaveLeveledUp": "You have leveled up!", "sendActivities": "Send activities", "getStarted": "Get Started", "getStartedBotChatDesc": "Chatting with AI is a great place to start and Pangea reading, writing, listening and speaking tools make it easy!", @@ -5016,7 +5015,7 @@ "groupChat": "Group Chat", "directMessage": "Direct Message", "newDirectMessage": "New direct message", - "speakingExercisesTooltip": "Speaking practice", + "speakingExercisesTooltip": "Speaking", "noChatsFoundHereYet": "No chats found here yet", "duration": "Duration", "transcriptionFailed": "Failed to transcribe audio", @@ -5031,5 +5030,11 @@ } }, "failedToFetchTranscription": "Failed to fetch transcription", - "deleteEmptySpaceDesc": "The space will be deleted for all participants. This action cannot be undone." -} \ No newline at end of file + "deleteEmptySpaceDesc": "The space will be deleted for all participants. This action cannot be undone.", + "customReaction": "Custom reaction", + "regenerate": "Regenerate", + "mySavedActivities": "My Saved Activities", + "noSavedActivities": "No saved activities", + "saveActivity": "Save this activity", + "yourSavedActivities": "Saved Activities" +} diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 083a91c55..a121bda0a 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -5407,7 +5407,6 @@ "activityPlannerTitle": "Planificador de Actividades", "topicLabel": "Tema", "topicPlaceholder": "Elige un tema...", - "modeLabel": "Modo", "modePlaceholder": "Elige un modo...", "learningObjectiveLabel": "Objetivo de Aprendizaje", "learningObjectivePlaceholder": "Elige un objetivo de aprendizaje...", @@ -5421,12 +5420,9 @@ "video": "Video", "nan": "No aplicable", "activityPlannerOverviewInstructionsBody": "¡Elige un tema, modo, objetivo de aprendizaje y genera una actividad para el chat!", - "myBookmarkedActivities": "Mis Actividades Marcadas", - "noBookmarkedActivities": "No hay actividades marcadas", "activityTitle": "Título de la Actividad", "addVocabulary": "Agregar vocabulario", "instructions": "Instrucciones", - "bookmark": "Marcar esta actividad", "numberOfLearners": "Número de aprendices", "mustBeInteger": "Debe ser un número entero, por ejemplo, 1, 2, 3, ...", "noLemmasFound": "No hay vocabulario con más de {xp} XP. ¡Sigue practicando!", @@ -5534,9 +5530,7 @@ "exploreMore": "Explorar más", "randomize": "Aleatorizar", "clear": "Limpiar", - "makeYourOwnActivity": "Crea tu propia actividad", "featuredActivities": "Destacadas", - "yourBookmarks": "Marcados", "goToChat": "Ir al chat", "save": "Guardar", "selectActivity": "Seleccionar actividad", @@ -5766,10 +5760,6 @@ "type": "String", "placeholders": {} }, - "@yourBookmarks": { - "type": "String", - "placeholders": {} - }, "@goToChat": { "type": "String", "placeholders": {} diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 87d240b4d..e225826a1 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -3551,7 +3551,6 @@ "activityPlannerTitle": "Trình lập hoạt động", "topicLabel": "Chủ đề", "topicPlaceholder": "Chọn một chủ đề...", - "modeLabel": "Chế độ", "modePlaceholder": "Chọn một chế độ...", "learningObjectiveLabel": "Mục tiêu học tập", "learningObjectivePlaceholder": "Chọn một mục tiêu học tập...", @@ -3565,12 +3564,9 @@ "video": "Video", "nan": "Không áp dụng", "activityPlannerOverviewInstructionsBody": "Chọn chủ đề, chế độ, mục tiêu học tập và tạo hoạt động cho cuộc trò chuyện!", - "myBookmarkedActivities": "Hoạt động đã đánh dấu", - "noBookmarkedActivities": "Chưa có hoạt động nào được đánh dấu", "activityTitle": "Tiêu đề hoạt động", "addVocabulary": "Thêm từ vựng", "instructions": "Hướng dẫn", - "bookmark": "Đánh dấu hoạt động", "numberOfLearners": "Số lượng người học", "mustBeInteger": "Phải là số nguyên, ví dụ: 1, 2, 3...", "noLemmasFound": "Chưa có từ vựng với hơn {xp} XP. Hãy luyện tập thêm!", @@ -3834,9 +3830,7 @@ "exploreMore": "Khám phá thêm", "randomize": "Ngẫu nhiên hóa", "clear": "Xóa", - "makeYourOwnActivity": "Tạo hoạt động của riêng bạn", "featuredActivities": "Nổi bật", - "yourBookmarks": "Đã đánh dấu", "goToChat": "Đi đến trò chuyện", "save": "Lưu", "selectActivity": "Chọn hoạt động", @@ -4032,10 +4026,6 @@ "type": "String", "placeholders": {} }, - "@yourBookmarks": { - "type": "String", - "placeholders": {} - }, "@goToChat": { "type": "String", "placeholders": {} diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 017ef0228..300305eaf 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -13,6 +13,7 @@ import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -30,7 +31,7 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; -import 'package:fluffychat/pangea/analytics_misc/level_up.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat/utils/unlocked_morphs_snackbar.dart'; @@ -501,12 +502,21 @@ class ChatController extends State ); if (audioFile == null) return; - matrix.audioPlayer!.setAudioSource( - BytesAudioSource( - audioFile.bytes, - audioFile.mimeType, - ), - ); + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + + File? file; + file = File('${tempDir.path}/${audioFile.name}'); + await file.writeAsBytes(audioFile.bytes); + matrix.audioPlayer!.setFilePath(file.path); + } else { + matrix.audioPlayer!.setAudioSource( + BytesAudioSource( + audioFile.bytes, + audioFile.mimeType, + ), + ); + } matrix.audioPlayer!.play(); }); @@ -1998,10 +2008,10 @@ class ChatController extends State OverlayUtil.showOverlay( context: context, child: overlayEntry!, - transformTargetId: "", position: OverlayPositionEnum.centered, onDismiss: clearSelectedEvents, blurBackground: true, + backgroundColor: Colors.black, ); // select the message diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index d322ffcae..160b34857 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -15,7 +15,6 @@ import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; -import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/utils/token_rendering_util.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; @@ -204,12 +203,14 @@ class HtmlMessage extends StatelessWidget { } } + int position = 0; for (final PangeaToken token in tokens ?? []) { final String tokenText = token.text.content; final substringIndex = result.indexWhere( (string) => string.contains(tokenText) && !(string.startsWith('<') && string.endsWith('>')), + position, ); if (substringIndex == -1) continue; @@ -229,9 +230,68 @@ class HtmlMessage extends StatelessWidget { '$tokenText', if (after.isNotEmpty) after, ]); + + position = substringIndex; } - return result.join(); + if (pangeaMessageEvent?.textDirection == TextDirection.rtl) { + for (int i = 0; i < result.length; i++) { + final tag = result[i]; + if (blockHtmlTags.contains(tag.htmlTagName) || + fullLineHtmlTag.contains(tag.htmlTagName)) { + if (i > 0 && result[i - 1] == ", ") { + result[i - 1] = ""; + } + result[i] = ", "; + } + } + result.removeWhere((element) => element == ""); + if (result[0] == ", ") result[0] = ""; + if (result.last == ", ") result.last = ""; + final inverted = _invertTags(result); + return inverted.join().trim(); + } + return result.join().trim(); + } + + List _invertTags(List tags) { + final List<(String, int)> stack = []; + final List<(int, int)> invertedTags = []; + for (int i = 0; i < tags.length; i++) { + final tag = tags[i]; + if (!tag.contains('<') || tag.contains(" + element.$1.htmlTagName == tag.htmlTagName && + !element.$1.contains(" a.tokens.contains(token), - ) - : null, ), MouseRegion( cursor: SystemMouseCursors.click, @@ -379,6 +437,7 @@ class HtmlMessage extends StatelessWidget { ? () => onClick?.call(token) : null, child: RichText( + textDirection: pangeaMessageEvent?.textDirection, text: TextSpan( children: [ LinkifySpan( @@ -388,6 +447,7 @@ class HtmlMessage extends StatelessWidget { color: renderer.backgroundColor( context, selected, + isNew, ), ), linkStyle: linkStyle, @@ -425,10 +485,7 @@ class HtmlMessage extends StatelessWidget { avatar: user.avatarUrl, uri: href, outerContext: context, - // #Pangea - // fontSize: fontSize, - fontSize: renderer.fontSize(context) ?? fontSize, - // Pangea# + fontSize: fontSize, color: linkStyle.color, // #Pangea userId: user.id, @@ -449,10 +506,7 @@ class HtmlMessage extends StatelessWidget { avatar: room?.avatar, uri: href, outerContext: context, - // #Pangea - // fontSize: fontSize, - fontSize: renderer.fontSize(context) ?? fontSize, - // Pangea# + fontSize: fontSize, color: linkStyle.color, ), ); @@ -530,6 +584,7 @@ class HtmlMessage extends StatelessWidget { color: renderer.backgroundColor( context, false, + false, ), ), ), @@ -545,6 +600,7 @@ class HtmlMessage extends StatelessWidget { color: renderer.backgroundColor( context, false, + false, ), ), // Pangea# @@ -1010,3 +1066,8 @@ extension on String { extension on dom.Element { dom.Element get rootElement => parent?.rootElement ?? this; } + +extension on String { + String get htmlTagName => + replaceAll('<', '').replaceAll('>', '').replaceAll('/', '').split(' ')[0]; +} diff --git a/lib/pages/chat_search/chat_search_message_tab.dart b/lib/pages/chat_search/chat_search_message_tab.dart index abf36f0a3..da9f2d63a 100644 --- a/lib/pages/chat_search/chat_search_message_tab.dart +++ b/lib/pages/chat_search/chat_search_message_tab.dart @@ -51,6 +51,13 @@ class ChatSearchMessageTab extends StatelessWidget { ); } final events = snapshot.data?.$1 ?? []; + // #Pangea + events.removeWhere( + (event) => + event.type != EventTypes.Message || + event.messageType != MessageTypes.Text, + ); + // Pangea# return SelectionArea( child: ListView.separated( @@ -143,15 +150,28 @@ class _MessageSearchResultListTile extends StatelessWidget { size: 16, ), const SizedBox(width: 8), - Text( - displayname, - ), - Expanded( + // #Pangea + // Text( + // displayname, + // ), + // Expanded( + // child: Text( + // ' | ${event.originServerTs.localizedTimeShort(context)}', + // style: const TextStyle(fontSize: 12), + // ), + // ), + Flexible( child: Text( - ' | ${event.originServerTs.localizedTimeShort(context)}', - style: const TextStyle(fontSize: 12), + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), + Text( + ' | ${event.originServerTs.localizedTimeShort(context)}', + style: const TextStyle(fontSize: 12), + ), + // Pangea# ], ), subtitle: Linkify( diff --git a/lib/pages/invitation_selection/invitation_selection.dart b/lib/pages/invitation_selection/invitation_selection.dart index 06509fae9..4929e27a9 100644 --- a/lib/pages/invitation_selection/invitation_selection.dart +++ b/lib/pages/invitation_selection/invitation_selection.dart @@ -35,35 +35,6 @@ class InvitationSelectionController extends State { String? get roomId => widget.roomId; // #Pangea - final viewportKey = GlobalKey(); - - final participantListItemHeight = 72.0; - final goToChatButtonHeight = 50.0; - final shareButtonsHeight = 150.0; - final padding = 16.0 * 2; - final fixedParticipantHeight = 72.0; - - double? viewportHeight; - double get availableHeight => - (viewportHeight ?? 0) - - goToChatButtonHeight - - shareButtonsHeight - - padding; - - bool showShareButtons(int numParticipants) => - (fixedParticipantHeight * numParticipants) < availableHeight; - - @override - initState() { - WidgetsBinding.instance.addPostFrameCallback((_) { - final context = viewportKey.currentContext; - if (context == null) return; - final renderBox = context.findRenderObject() as RenderBox; - final size = renderBox.size; - setState(() => viewportHeight = size.height); - }); - super.initState(); - } List? get participants { final room = Matrix.of(context).client.getRoomById(roomId!); diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index 59c6f6ad0..2df3b6a56 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -8,12 +8,10 @@ import 'package:matrix/matrix.dart'; import 'package:universal_html/html.dart' as html; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/space_invite_buttons.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; @@ -107,165 +105,153 @@ class InvitationSelectionView extends StatelessWidget { // #Pangea withScrolling: false, // Pangea# - child: Stack( - alignment: Alignment.bottomCenter, + child: Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: 450, - child: CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}", - errorWidget: (context, url, error) => const SizedBox(), - placeholder: (context, url) => const Center( - child: CircularProgressIndicator.adaptive(), + // #Pangea + // padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.only( + bottom: 16.0, + left: 16.0, + right: 16.0, + ), + // Pangea# + child: TextField( + textInputAction: TextInputAction.search, + decoration: InputDecoration( + filled: true, + fillColor: theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), ), + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + // #Pangea + hintText: L10n.of(context).inviteStudentByUserName, + // hintText: L10n.of(context).inviteContactToGroup(groupName), + // Pangea# + prefixIcon: controller.loading + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 12, + ), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ) + : const Icon(Icons.search_outlined), ), + onChanged: controller.searchUserWithCoolDown, ), ), - Column( - children: [ - Padding( - // #Pangea - // padding: const EdgeInsets.all(16.0), - padding: const EdgeInsets.only( - bottom: 16.0, - left: 16.0, - right: 16.0, - ), - // Pangea# - child: TextField( - textInputAction: TextInputAction.search, - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - hintStyle: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - // #Pangea - hintText: L10n.of(context).inviteStudentByUserName, - // hintText: L10n.of(context).inviteContactToGroup(groupName), - // Pangea# - prefixIcon: controller.loading - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 12, - ), - child: SizedBox.square( - dimension: 24, + // #Pangea + // StreamBuilder( + Expanded( + child: StreamBuilder( + // stream: room.client.onRoomState.stream + // .where((update) => update.roomId == room.id), + stream: room.client.onRoomState.stream + .where((update) => update.roomId == room.id) + .rateLimit(const Duration(seconds: 1)), + // Pangea# + builder: (context, snapshot) { + final participants = + room.getParticipants().map((user) => user.id).toSet(); + return controller.foundProfiles.isNotEmpty + ? ListView.builder( + // #Pangea + // physics: const NeverScrollableScrollPhysics(), + // shrinkWrap: true, + // Pangea# + itemCount: controller.foundProfiles.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + profile: controller.foundProfiles[i], + isMember: participants.contains( + controller.foundProfiles[i].userId, + ), + onTap: () => controller.inviteAction( + context, + controller.foundProfiles[i].userId, + controller.foundProfiles[i].displayName ?? + controller + .foundProfiles[i].userId.localpart ?? + L10n.of(context).user, + ), + ), + ) + : FutureBuilder>( + future: controller.getContacts(context), + builder: (BuildContext context, snapshot) { + if (!snapshot.hasData) { + return const Center( child: CircularProgressIndicator.adaptive( strokeWidth: 2, ), - ), - ) - : const Icon(Icons.search_outlined), - ), - onChanged: controller.searchUserWithCoolDown, - ), - ), - // #Pangea - // StreamBuilder( - Expanded( - key: controller.viewportKey, - child: StreamBuilder( - // stream: room.client.onRoomState.stream - // .where((update) => update.roomId == room.id), - stream: room.client.onRoomState.stream - .where((update) => update.roomId == room.id) - .rateLimit(const Duration(seconds: 1)), - // Pangea# - builder: (context, snapshot) { - final participants = - room.getParticipants().map((user) => user.id).toSet(); - return controller.foundProfiles.isNotEmpty - ? ListView.builder( + ); + } + final contacts = snapshot.data!; + return ListView.builder( // #Pangea // physics: const NeverScrollableScrollPhysics(), // shrinkWrap: true, - // Pangea# - itemCount: controller.foundProfiles.length, - itemBuilder: (BuildContext context, int i) => - _InviteContactListTile( - profile: controller.foundProfiles[i], - isMember: participants.contains( - controller.foundProfiles[i].userId, - ), - onTap: () => controller.inviteAction( - context, - controller.foundProfiles[i].userId, - controller.foundProfiles[i].displayName ?? - controller - .foundProfiles[i].userId.localpart ?? - L10n.of(context).user, - ), - ), - ) - : FutureBuilder>( - future: controller.getContacts(context), - builder: (BuildContext context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, + // itemCount: contacts.length, + // itemBuilder: (BuildContext context, int i) => + // _InviteContactListTile( + itemCount: contacts.length + 1, + itemBuilder: (BuildContext context, int i) { + if (i == contacts.length) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 450, + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}", + errorWidget: (context, url, error) => + const SizedBox(), + placeholder: (context, url) => + const Center( + child: CircularProgressIndicator + .adaptive(), + ), + ), ), ); } - final contacts = snapshot.data!; - return ListView.builder( - // #Pangea - // physics: const NeverScrollableScrollPhysics(), - // shrinkWrap: true, - // itemCount: contacts.length, - // itemBuilder: (BuildContext context, int i) => - // _InviteContactListTile( - itemCount: contacts.length + 1, - itemBuilder: (BuildContext context, int i) { - if (i == contacts.length) { - final showButtons = controller - .showShareButtons(contacts.length); - return AnimatedOpacity( - duration: - FluffyThemes.animationDuration, - opacity: showButtons ? 1.0 : 0.0, - child: SpaceInviteButtons(room: room), - ); - } - - return _InviteContactListTile( - // Pangea# - user: contacts[i], - profile: Profile( - avatarUrl: contacts[i].avatarUrl, - displayName: contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, - userId: contacts[i].id, - ), - isMember: - participants.contains(contacts[i].id), - onTap: () => controller.inviteAction( - context, - contacts[i].id, - contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, - ), - ); - }, + return _InviteContactListTile( + // Pangea# + user: contacts[i], + profile: Profile( + avatarUrl: contacts[i].avatarUrl, + displayName: contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + userId: contacts[i].id, + ), + isMember: + participants.contains(contacts[i].id), + onTap: () => controller.inviteAction( + context, + contacts[i].id, + contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + ), ); }, ); - }, - ), - ), - ], + }, + ); + }, + ), ), Padding( padding: const EdgeInsets.all(16.0), @@ -355,6 +341,8 @@ class _InviteContactListTile extends StatelessWidget { style: const TextStyle( fontSize: 12.0, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), LevelDisplayName(userId: profile.userId), ], diff --git a/lib/pangea/activity_generator/activity_generator.dart b/lib/pangea/activity_generator/activity_generator.dart index 35c517b17..e2803ef8a 100644 --- a/lib/pangea/activity_generator/activity_generator.dart +++ b/lib/pangea/activity_generator/activity_generator.dart @@ -10,7 +10,6 @@ import 'package:fluffychat/pangea/activity_planner/activity_mode_list_repo.dart' import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_response.dart'; import 'package:fluffychat/pangea/activity_planner/learning_objective_list_repo.dart'; import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; @@ -73,7 +72,7 @@ class ActivityGeneratorState extends State { ActivitySettingRequestSchema get req => ActivitySettingRequestSchema( langCode: - MatrixState.pangeaController.languageController.userL2?.langCode ?? + MatrixState.pangeaController.languageController.userL1?.langCode ?? LanguageKeys.defaultLanguage, ); @@ -166,11 +165,6 @@ class ActivityGeneratorState extends State { setState(() => selectedCefrLevel = value); } - void setSelectedMedia(MediaEnum? value) { - if (value == null) return; - setState(() => selectedMedia = value); - } - Future get _selectedMode async { final modes = await modeItems; return modes.firstWhereOrNull( @@ -203,30 +197,25 @@ class ActivityGeneratorState extends State { }); } - Future onEdit(int index, ActivityPlanModel updatedActivity) async { - // in this case we're editing an activity plan that was generated recently - // via the repo and should be updated in the cached response - if (activities != null) { - activities?[index] = updatedActivity; - ActivityPlanGenerationRepo.set( - planRequest, - ActivityPlanResponse(activityPlans: activities!), - ); - } - - setState(() {}); + void clearActivities() { + setState(() { + activities = null; + filename = null; + }); } - void update() => setState(() {}); - - Future generate() async { + Future generate({bool force = false}) async { setState(() { loading = true; error = null; + activities = null; }); try { - final resp = await ActivityPlanGenerationRepo.get(planRequest); + final resp = await ActivityPlanGenerationRepo.get( + planRequest, + force: force, + ); activities = resp.activityPlans; await _setModeImageURL(); } catch (e, s) { diff --git a/lib/pangea/activity_generator/activity_generator_view.dart b/lib/pangea/activity_generator/activity_generator_view.dart index 862d523ae..a7c5ffa1c 100644 --- a/lib/pangea/activity_generator/activity_generator_view.dart +++ b/lib/pangea/activity_generator/activity_generator_view.dart @@ -61,6 +61,7 @@ class ActivityGeneratorView extends StatelessWidget { room: controller.room, builder: (c) { return ActivityPlanCard( + regenerate: () => controller.generate(force: true), controller: c, ); }, @@ -72,6 +73,16 @@ class ActivityGeneratorView extends StatelessWidget { return Scaffold( appBar: AppBar( title: Text(L10n.of(context).makeYourOwnActivity), + leading: BackButton( + onPressed: () { + if (controller.activities != null && + controller.activities!.isNotEmpty) { + controller.clearActivities(); + } else { + Navigator.of(context).pop(); + } + }, + ), ), body: body ?? Center( diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 68ed306a3..c2fb38eca 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -20,10 +20,12 @@ import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_en import 'package:fluffychat/widgets/future_loading_dialog.dart'; class ActivityPlanCard extends StatefulWidget { + final VoidCallback regenerate; final ActivityPlannerBuilderState controller; const ActivityPlanCard({ super.key, + required this.regenerate, required this.controller, }); @@ -108,6 +110,7 @@ class ActivityPlanCardState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final l10n = L10n.of(context); return Center( child: ConstrainedBox( @@ -121,8 +124,11 @@ class ActivityPlanCardState extends State { AnimatedSize( duration: FluffyThemes.animationDuration, child: Stack( + alignment: Alignment.bottomCenter, children: [ Container( + width: 200.0, + padding: const EdgeInsets.all(16.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.0), ), @@ -131,6 +137,7 @@ class ActivityPlanCardState extends State { child: widget.controller.imageURL != null || widget.controller.avatar != null ? ClipRRect( + borderRadius: BorderRadius.circular(20.0), child: widget.controller.avatar == null ? CachedNetworkImage( fit: BoxFit.cover, @@ -156,14 +163,17 @@ class ActivityPlanCardState extends State { ), ), if (widget.controller.isEditing) - Positioned( - top: 10.0, - right: 10.0, - child: IconButton( - icon: const Icon(Icons.upload_outlined), - onPressed: widget.controller.selectAvatar, - style: IconButton.styleFrom( - backgroundColor: Colors.black, + InkWell( + borderRadius: BorderRadius.circular(90), + onTap: widget.controller.selectAvatar, + child: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.secondary, + radius: 20.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 20.0, + color: Theme.of(context).colorScheme.onSecondary, ), ), ), @@ -205,8 +215,8 @@ class ActivityPlanCardState extends State { ), icon: Icon( _isBookmarked - ? Icons.bookmark - : Icons.bookmark_border, + ? Icons.save + : Icons.save_outlined, ), ), ], @@ -368,47 +378,153 @@ class ActivityPlanCardState extends State { ), ], const SizedBox(height: itemPadding), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Tooltip( - message: !widget.controller.isEditing - ? l10n.edit - : l10n.saveChanges, - child: IconButton( - icon: Icon( - !widget.controller.isEditing - ? Icons.edit - : Icons.save, + widget.controller.isEditing + ? Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.controller.saveEdits, + child: Row( + children: [ + const Icon(Icons.save), + Expanded( + child: Text( + L10n.of(context).save, + textAlign: TextAlign.center, + ), + ), + ], + ), ), - onPressed: () => !widget.controller.isEditing - ? setState(() { - widget.controller.isEditing = true; - }) - : widget.controller.saveEdits(), - isSelected: widget.controller.isEditing, ), - ), - if (widget.controller.isEditing) - Tooltip( - message: l10n.cancel, - child: IconButton( - icon: const Icon(Icons.cancel), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), onPressed: widget.controller.clearEdits, + child: Row( + children: [ + const Icon(Icons.cancel), + Expanded( + child: Text( + L10n.of(context).cancel, + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), - ], - ), - ElevatedButton.icon( - onPressed: - !widget.controller.isEditing ? _onLaunch : null, - icon: const Icon(Icons.send), - label: Text(l10n.launchActivityButton), - ), - ], - ), + ], + ) + : Column( + spacing: 12.0, + children: [ + Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + child: Row( + children: [ + const Icon(Icons.edit), + Expanded( + child: Text( + L10n.of(context).edit, + textAlign: TextAlign.center, + ), + ), + ], + ), + onPressed: () => + widget.controller.setEditing(true), + ), + ), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.regenerate, + child: Row( + children: [ + const Icon(Icons.lightbulb_outline), + Expanded( + child: Text( + L10n.of(context).regenerate, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: _onLaunch, + child: Row( + children: [ + const Icon(Icons.send), + Expanded( + child: Text( + L10n.of(context) + .launchActivityButton, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), ], ), ), diff --git a/lib/pangea/activity_planner/activity_plan_generation_repo.dart b/lib/pangea/activity_planner/activity_plan_generation_repo.dart index 70e079e85..261b41a20 100644 --- a/lib/pangea/activity_planner/activity_plan_generation_repo.dart +++ b/lib/pangea/activity_planner/activity_plan_generation_repo.dart @@ -18,9 +18,12 @@ class ActivityPlanGenerationRepo { _activityPlanStorage.write(request.storageKey, response.toJson()); } - static Future get(ActivityPlanRequest request) async { + static Future get( + ActivityPlanRequest request, { + bool force = false, + }) async { final cachedJson = _activityPlanStorage.read(request.storageKey); - if (cachedJson != null) { + if (cachedJson != null && !force) { final cached = ActivityPlanResponse.fromJson(cachedJson); return cached; diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart index 42e84bce2..293d75a35 100644 --- a/lib/pangea/activity_planner/activity_planner_builder.dart +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -7,6 +7,8 @@ import 'package:http/http.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; +import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; @@ -21,18 +23,12 @@ class ActivityPlannerBuilder extends StatefulWidget { final Widget Function(ActivityPlannerBuilderState) builder; - final Future Function( - String, - ActivityPlanModel, - )? onEdit; - const ActivityPlannerBuilder({ super.key, required this.initialActivity, this.initialFilename, this.room, required this.builder, - this.onEdit, }); @override @@ -75,16 +71,18 @@ class ActivityPlannerBuilderState extends State { Room? get room => widget.room; - ActivityPlanModel get updatedActivity { + ActivityPlanRequest get updatedRequest { final int participants = int.tryParse(participantsController.text.trim()) ?? widget.initialActivity.req.numberOfParticipants; - final updatedReq = widget.initialActivity.req; updatedReq.numberOfParticipants = participants; updatedReq.cefrLevel = languageLevel; + return updatedReq; + } + ActivityPlanModel get updatedActivity { return ActivityPlanModel( - req: updatedReq, + req: updatedRequest, title: titleController.text, learningObjective: learningObjectivesController.text, instructions: instructionsController.text, @@ -112,7 +110,28 @@ class ActivityPlannerBuilderState extends State { imageURL = widget.initialActivity.imageURL; filename = widget.initialFilename; - await _setAvatarByURL(); + if (widget.initialActivity.imageURL != null) { + await _setAvatarByURL(widget.initialActivity.imageURL!); + } + if (mounted) setState(() {}); + } + + Future overrideActivity(ActivityPlanModel override) async { + avatar = null; + filename = null; + imageURL = null; + + titleController.text = override.title; + learningObjectivesController.text = override.learningObjective; + instructionsController.text = override.instructions; + participantsController.text = override.req.numberOfParticipants.toString(); + vocab.clear(); + vocab.addAll(override.vocab); + languageLevel = override.req.cefrLevel; + + if (override.imageURL != null) { + await _setAvatarByURL(override.imageURL!); + } if (mounted) setState(() {}); } @@ -158,24 +177,22 @@ class ActivityPlannerBuilderState extends State { } } - Future _setAvatarByURL() async { - if (widget.initialActivity.imageURL == null) return; + Future _setAvatarByURL(String url) async { try { if (avatar == null) { - if (widget.initialActivity.imageURL!.startsWith("mxc")) { + if (url.startsWith("mxc")) { final client = Matrix.of(context).client; - final mxcUri = Uri.parse(widget.initialActivity.imageURL!); + final mxcUri = Uri.parse(url); final data = await client.downloadMxcCached(mxcUri); avatar = data; filename = Uri.encodeComponent( mxcUri.pathSegments.last, ); } else { - final Response response = - await http.get(Uri.parse(widget.initialActivity.imageURL!)); + final Response response = await http.get(Uri.parse(url)); avatar = response.bodyBytes; filename = Uri.encodeComponent( - Uri.parse(widget.initialActivity.imageURL!).pathSegments.last, + Uri.parse(url).pathSegments.last, ); } } @@ -206,12 +223,10 @@ class ActivityPlannerBuilderState extends State { if (!formKey.currentState!.validate()) return; await updateImageURL(); setEditing(false); - if (widget.onEdit != null) { - await widget.onEdit!( - widget.initialActivity.bookmarkId, - updatedActivity, - ); - } + + await BookmarkedActivitiesRepo.remove(widget.initialActivity.bookmarkId); + await BookmarkedActivitiesRepo.save(updatedActivity); + if (mounted) setState(() {}); } Future clearEdits() async { diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index 166cbb2d9..8cc9c4f4b 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -14,8 +14,8 @@ enum PageMode { } class ActivityPlannerPage extends StatefulWidget { - final String? roomID; - const ActivityPlannerPage({super.key, this.roomID}); + final String roomID; + const ActivityPlannerPage({super.key, required this.roomID}); @override ActivityPlannerPageState createState() => ActivityPlannerPageState(); @@ -23,9 +23,7 @@ class ActivityPlannerPage extends StatefulWidget { class ActivityPlannerPageState extends State { PageMode pageMode = PageMode.featuredActivities; - Room? get room => widget.roomID != null - ? Matrix.of(context).client.getRoomById(widget.roomID!) - : null; + Room? get room => Matrix.of(context).client.getRoomById(widget.roomID); void _setPageMode(PageMode? mode) { if (mode == null) return; @@ -82,7 +80,7 @@ class ActivityPlannerPageState extends State { ), ButtonSegment( value: PageMode.savedActivities, - label: Text(L10n.of(context).yourBookmarks), + label: Text(L10n.of(context).yourSavedActivities), ), ], ), diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart index 94ee72890..69c6a9316 100644 --- a/lib/pangea/activity_planner/activity_planner_page_appbar.dart +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -12,11 +12,11 @@ import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; class ActivityPlannerPageAppBar extends StatelessWidget implements PreferredSizeWidget { final PageMode pageMode; - final String? roomID; + final String roomID; const ActivityPlannerPageAppBar({ required this.pageMode, - this.roomID, + required this.roomID, super.key, }); @@ -44,10 +44,10 @@ class ActivityPlannerPageAppBar extends StatelessWidget ? Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.bookmarks), + const Icon(Icons.save), const SizedBox(width: 8), Flexible( - child: Text(l10n.myBookmarkedActivities), + child: Text(l10n.mySavedActivities), ), ], ) @@ -71,9 +71,8 @@ class ActivityPlannerPageAppBar extends StatelessWidget alignment: Alignment.center, child: InkWell( customBorder: const CircleBorder(), - onTap: () => roomID != null - ? context.go('/rooms/$roomID/details/planner/generator') - : context.go("/rooms/homepage/planner/generator"), + onTap: () => + context.go('/rooms/$roomID/details/planner/generator'), child: Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, @@ -114,9 +113,8 @@ class ActivityPlannerPageAppBar extends StatelessWidget ) : IconButton( icon: const Icon(Icons.add), - onPressed: () => roomID != null - ? context.go('/rooms/$roomID/details/planner/generator') - : context.go("/rooms/homepage/planner/generator"), + onPressed: () => + context.go('/rooms/$roomID/details/planner/generator'), ), ], ); diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart index e11bf9f0b..1d621a01d 100644 --- a/lib/pangea/activity_planner/bookmarked_activity_list.dart +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -36,15 +36,6 @@ class BookmarkedActivitiesListState extends State { double get cardHeight => _isColumnMode ? 325.0 : 250.0; double get cardWidth => _isColumnMode ? 225.0 : 150.0; - Future _onEdit( - String activityId, - ActivityPlanModel activity, - ) async { - await BookmarkedActivitiesRepo.remove(activityId); - await BookmarkedActivitiesRepo.save(activity); - if (mounted) setState(() {}); - } - @override Widget build(BuildContext context) { final l10n = L10n.of(context); @@ -53,7 +44,7 @@ class BookmarkedActivitiesListState extends State { child: Container( constraints: const BoxConstraints(maxWidth: 200), child: Text( - l10n.noBookmarkedActivities, + l10n.noSavedActivities, textAlign: TextAlign.center, ), ), @@ -77,7 +68,6 @@ class BookmarkedActivitiesListState extends State { builder: (context) { return ActivityPlannerBuilder( initialActivity: activity, - onEdit: _onEdit, room: widget.room, builder: (controller) { return ActivitySuggestionDialog( diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_card.dart index e86957bc2..62a097f11 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_card.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_card.dart @@ -180,7 +180,7 @@ class ActivitySuggestionCard extends StatelessWidget { right: 4.0, child: IconButton( icon: Icon( - isBookmarked ? Icons.bookmark : Icons.bookmark_border, + isBookmarked ? Icons.save : Icons.save_outlined, color: Theme.of(context).colorScheme.onPrimaryContainer, ), onPressed: onPressed != null diff --git a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart index b2b76007f..360003f01 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart @@ -144,6 +144,15 @@ class ActivitySuggestionCarouselState }); } + void _onReplaceActivity(ActivityPlanModel a) { + final index = _currentIndex; + if (index == null || index < 0 || index >= _activityItems.length) { + return; + } + _activityItems[index] = a; + setState(() => _currentActivity = a); + } + void _onClickCard() { if (widget.selectedActivity == _currentActivity) { widget.onActivitySelected( @@ -163,6 +172,7 @@ class ActivitySuggestionCarouselState return ActivitySuggestionDialog( controller: controller, buttonText: L10n.of(context).selectActivity, + replaceActivity: _onReplaceActivity, onLaunch: () => widget.onActivitySelected( controller.updatedActivity, controller.avatar, diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index 287bffb83..2e8802bcd 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -7,10 +7,13 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -26,11 +29,13 @@ class ActivitySuggestionDialog extends StatefulWidget { final String buttonText; final VoidCallback? onLaunch; + final Function(ActivityPlanModel)? replaceActivity; const ActivitySuggestionDialog({ required this.controller, required this.buttonText, this.onLaunch, + this.replaceActivity, super.key, }); @@ -42,6 +47,9 @@ class ActivitySuggestionDialog extends StatefulWidget { class ActivitySuggestionDialogState extends State { _PageMode _pageMode = _PageMode.activity; + bool _loading = false; + Object? _error; + double get _width => FluffyThemes.isColumnMode(context) ? 400.0 : MediaQuery.of(context).size.width; @@ -72,6 +80,43 @@ class ActivitySuggestionDialogState extends State { }); } + Future _onRegenerate() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final resp = await ActivityPlanGenerationRepo.get( + widget.controller.updatedRequest, + force: true, + ); + final plan = resp.activityPlans.firstOrNull; + if (plan == null) { + throw Exception("No activity plan generated"); + } + + widget.replaceActivity?.call(plan); + await widget.controller.overrideActivity(plan); + } catch (e, s) { + _error = e; + ErrorHandler.logError( + e: e, + s: s, + data: { + "request": widget.controller.updatedRequest.toJson(), + }, + ); + return; + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -82,8 +127,29 @@ class ActivitySuggestionDialogState extends State { decoration: BoxDecoration( color: theme.colorScheme.surface, ), - child: _pageMode == _PageMode.activity - ? Form( + child: Builder( + builder: (context) { + if (_pageMode == _PageMode.activity) { + if (_error != null) { + return Center( + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error, color: theme.colorScheme.error), + Text(L10n.of(context).oopsSomethingWentWrong), + ], + ), + ); + } + + if (_loading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + return Form( key: widget.controller.formKey, child: Column( mainAxisSize: MainAxisSize.min, @@ -96,69 +162,78 @@ class ActivitySuggestionDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ Stack( - alignment: Alignment.center, + alignment: Alignment.bottomCenter, children: [ - SizedBox( - width: _width, - child: widget.controller.avatar != null - ? Image.memory( - widget.controller.avatar!, - fit: BoxFit.cover, - ) - : widget.controller.updatedActivity - .imageURL != - null - ? widget.controller.updatedActivity - .imageURL! - .startsWith("mxc") - ? MxcImage( - uri: Uri.parse( - widget + Container( + padding: const EdgeInsets.all(24.0), + width: (_width / 2) + 42.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: widget.controller.avatar != null + ? Image.memory( + widget.controller.avatar!, + fit: BoxFit.cover, + ) + : widget.controller.updatedActivity + .imageURL != + null + ? widget.controller + .updatedActivity.imageURL! + .startsWith("mxc") + ? MxcImage( + uri: Uri.parse( + widget + .controller + .updatedActivity + .imageURL!, + ), + width: _width / 2, + height: 200, + cacheKey: widget + .controller + .updatedActivity + .bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: widget .controller .updatedActivity .imageURL!, - ), - width: _width, - height: 200, - cacheKey: widget - .controller - .updatedActivity - .bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: widget - .controller - .updatedActivity - .imageURL!, - fit: BoxFit.cover, - placeholder: - (context, url) => - const Center( - child: - CircularProgressIndicator(), - ), - errorWidget: ( - context, - url, - error, - ) => - const SizedBox(), - ) - : null, + fit: BoxFit.cover, + placeholder: ( + context, + url, + ) => + const Center( + child: + CircularProgressIndicator(), + ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ) + : null, + ), ), if (widget.controller.isEditing) - Positioned( - bottom: 8.0, - child: InkWell( - borderRadius: BorderRadius.circular(90), - onTap: widget.controller.selectAvatar, - child: const CircleAvatar( - radius: 24.0, - child: Icon( - Icons.add_a_photo_outlined, - size: 24.0, - ), + InkWell( + borderRadius: BorderRadius.circular(90), + onTap: widget.controller.selectAvatar, + child: CircleAvatar( + backgroundColor: Theme.of(context) + .colorScheme + .secondary, + radius: 20.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 20.0, + color: Theme.of(context) + .colorScheme + .onSecondary, ), ), ), @@ -338,7 +413,9 @@ class ActivitySuggestionDialogState extends State { decoration: BoxDecoration( color: theme .colorScheme.primary - .withAlpha(20), + .withAlpha( + 20, + ), borderRadius: BorderRadius .circular( @@ -352,14 +429,18 @@ class ActivitySuggestionDialogState extends State { child: GestureDetector( onTap: () => widget .controller - .removeVocab(i), + .removeVocab( + i, + ), child: Row( spacing: 4.0, mainAxisSize: MainAxisSize .min, children: [ - Text(vocab.lemma), + Text( + vocab.lemma, + ), const Icon( Icons.close, size: 12.0, @@ -397,7 +478,9 @@ class ActivitySuggestionDialogState extends State { decoration: BoxDecoration( color: theme .colorScheme.primary - .withAlpha(20), + .withAlpha( + 20, + ), borderRadius: BorderRadius .circular( @@ -429,8 +512,9 @@ class ActivitySuggestionDialogState extends State { controller: widget .controller.vocabController, decoration: InputDecoration( - hintText: L10n.of(context) - .addVocabulary, + hintText: L10n.of( + context, + ).addVocabulary, ), maxLines: 1, onFieldSubmitted: (_) => widget @@ -439,8 +523,9 @@ class ActivitySuggestionDialogState extends State { ), ), IconButton( - padding: - const EdgeInsets.all(0.0), + padding: const EdgeInsets.all( + 0.0, + ), constraints: const BoxConstraints(), // override default min size of 48px iconSize: 16.0, @@ -462,101 +547,173 @@ class ActivitySuggestionDialogState extends State { ), Padding( padding: const EdgeInsets.all(16.0), - child: Row( - spacing: 6.0, - children: [ - if (widget.controller.isEditing) - Expanded( - child: ElevatedButton( - onPressed: widget.controller.saveEdits, - style: ElevatedButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.all(6.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - backgroundColor: theme.colorScheme.primary, - foregroundColor: - theme.colorScheme.onPrimary, - ), - child: Text( - L10n.of(context).save, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onPrimary, + child: widget.controller.isEditing + ? Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.controller.saveEdits, + child: Row( + children: [ + const Icon(Icons.save), + Expanded( + child: Text( + L10n.of(context).save, + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), - ), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.controller.clearEdits, + child: Row( + children: [ + const Icon(Icons.cancel), + Expanded( + child: Text( + L10n.of(context).cancel, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], ) - else - Expanded( - child: ElevatedButton( - onPressed: () async { - if (!widget.controller.formKey.currentState! - .validate()) { - return; - } - _launchActivity(); - }, - style: ElevatedButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.all(6.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - backgroundColor: theme.colorScheme.primary, - foregroundColor: - theme.colorScheme.onPrimary, + : Column( + spacing: 12.0, + children: [ + Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + child: Row( + children: [ + const Icon(Icons.edit), + Expanded( + child: Text( + L10n.of(context).edit, + textAlign: TextAlign.center, + ), + ), + ], + ), + onPressed: () => widget.controller + .setEditing(true), + ), + ), + if (widget.replaceActivity != null) + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme.colorScheme + .onPrimaryContainer, + padding: + const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: _onRegenerate, + child: Row( + children: [ + const Icon( + Icons.lightbulb_outline, + ), + Expanded( + child: Text( + L10n.of(context).regenerate, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], ), - child: Text( - widget.buttonText, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onPrimary, - ), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: _launchActivity, + child: Row( + children: [ + const Icon(Icons.send), + Expanded( + child: Text( + L10n.of(context) + .launchActivityButton, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], ), - ), + ], ), - if (widget.controller.isEditing) - IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - padding: const EdgeInsets.all(6.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 24.0, - icon: const Icon(Icons.close_outlined), - onPressed: () async { - await widget.controller.clearEdits(); - widget.controller.setEditing(false); - }, - ) - else - IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - padding: const EdgeInsets.all(6.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 24.0, - icon: const Icon(Icons.edit_outlined), - onPressed: () => - widget.controller.setEditing(true), - ), - ], - ), ), ], ), - ) - : ActivityRoomSelection( - controller: widget.controller, - backButton: BackButton( - onPressed: () => _setPageMode( - _PageMode.activity, - ), + ); + } + + return ActivityRoomSelection( + controller: widget.controller, + backButton: BackButton( + onPressed: () => _setPageMode( + _PageMode.activity, ), ), + ); + }, + ), ), if (_pageMode == _PageMode.activity) Positioned( diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 82d0ca74b..7d43c9180 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; import 'package:shimmer/shimmer.dart'; @@ -25,14 +25,11 @@ import 'package:fluffychat/widgets/matrix.dart'; class ActivitySuggestionsArea extends StatefulWidget { final Axis? scrollDirection; - final bool showTitle; - final Room? room; const ActivitySuggestionsArea({ super.key, this.scrollDirection, - this.showTitle = false, this.room, }); @override @@ -82,6 +79,20 @@ class ActivitySuggestionsAreaState extends State { MatrixState.pangeaController.languageController.userL2?.langCode ?? LanguageKeys.defaultLanguage; + ActivityPlanRequest get _request { + return ActivityPlanRequest( + topic: "", + mode: "", + objective: "", + media: MediaEnum.nan, + cefrLevel: LanguageLevelTypeEnum.a1, + languageOfInstructions: instructionLanguage, + targetLanguage: targetLanguage, + numberOfParticipants: 3, + count: 5, + ); + } + Future _setActivityItems({int retries = 0}) async { if (retries > 3) { if (mounted) { @@ -99,18 +110,7 @@ class ActivitySuggestionsAreaState extends State { _loading = true; }); - final ActivityPlanRequest request = ActivityPlanRequest( - topic: "", - mode: "", - objective: "", - media: MediaEnum.nan, - cefrLevel: LanguageLevelTypeEnum.a1, - languageOfInstructions: instructionLanguage, - targetLanguage: targetLanguage, - numberOfParticipants: 3, - count: 5, - ); - final resp = await ActivitySearchRepo.get(request).timeout( + final resp = await ActivitySearchRepo.get(_request).timeout( const Duration(seconds: 5), onTimeout: () { if (mounted) { @@ -138,10 +138,13 @@ class ActivitySuggestionsAreaState extends State { } } + void _onReplaceActivity(int index, ActivityPlanModel a) { + setState(() => _activityItems[index] = a); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isColumnMode = FluffyThemes.isColumnMode(context); final List cards = _loading ? List.generate(5, (i) { @@ -159,7 +162,7 @@ class ActivitySuggestionsAreaState extends State { ); }) : _activityItems - .map((activity) { + .mapIndexed((index, activity) { return ActivitySuggestionCard( activity: activity, onPressed: () { @@ -173,6 +176,8 @@ class ActivitySuggestionsAreaState extends State { return ActivitySuggestionDialog( controller: controller, buttonText: L10n.of(context).launch, + replaceActivity: (a) => + _onReplaceActivity(index, a), ); }, ); @@ -196,29 +201,6 @@ class ActivitySuggestionsAreaState extends State { spacing: 8.0, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.showTitle) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - L10n.of(context).chatWithActivities, - style: isColumnMode - ? theme.textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold) - : theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.event_note_outlined), - onPressed: () => context.go('/rooms/homepage/planner'), - tooltip: L10n.of(context).activityPlannerTitle, - ), - ], - ), AnimatedSize( duration: FluffyThemes.animationDuration, child: (_timeout || !_loading && cards.isEmpty) diff --git a/lib/pangea/activity_suggestions/suggestions_page.dart b/lib/pangea/activity_suggestions/suggestions_page.dart deleted file mode 100644 index b8d2a9038..000000000 --- a/lib/pangea/activity_suggestions/suggestions_page.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:go_router/go_router.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; -import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; -import 'package:fluffychat/pangea/public_spaces/public_spaces_area.dart'; -import 'package:fluffychat/widgets/navigation_rail.dart'; - -class SuggestionsPage extends StatelessWidget { - const SuggestionsPage({super.key}); - - @override - Widget build(BuildContext context) { - final isColumnMode = FluffyThemes.isColumnMode(context); - return Scaffold( - resizeToAvoidBottomInset: true, - body: SafeArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isColumnMode && AppConfig.displayNavigationRail) ...[ - SpacesNavigationRail( - activeSpaceId: null, - onGoToChats: () => context.go('/rooms'), - onGoToSpaceId: (spaceId) => - context.go('/rooms?spaceId=$spaceId'), - ), - Container( - color: Theme.of(context).dividerColor, - width: 1, - ), - ], - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 16.0, - ), - child: Column( - spacing: 24.0, - children: [ - if (!isColumnMode) const LearningProgressIndicators(), - const ActivitySuggestionsArea( - showTitle: true, - scrollDirection: Axis.horizontal, - ), - const PublicSpacesArea(), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index 9cb085479..a4ef88441 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -12,7 +12,6 @@ import 'package:fluffychat/pangea/analytics_downloads/analytics_download_button. import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; @@ -26,11 +25,13 @@ class AnalyticsPopupWrapper extends StatefulWidget { this.constructZoom, required this.view, this.backButtonOverride, + this.showAppBar = true, }); final ConstructTypeEnum view; final ConstructIdentifier? constructZoom; final Widget? backButtonOverride; + final bool showAppBar; @override AnalyticsPopupWrapperState createState() => AnalyticsPopupWrapperState(); @@ -58,6 +59,19 @@ class AnalyticsPopupWrapperState extends State { }); } + @override + void didUpdateWidget(covariant AnalyticsPopupWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.constructZoom != oldWidget.constructZoom) { + setConstructZoom(widget.constructZoom); + } + if (widget.view != oldWidget.view) { + localView = widget.view; + localConstructZoom = null; + setState(() {}); + } + } + @override void dispose() { searchController.dispose(); @@ -109,74 +123,82 @@ class AnalyticsPopupWrapperState extends State { @override Widget build(BuildContext context) { - return FullWidthDialog( - dialogContent: Scaffold( - appBar: AppBar( - title: kIsWeb - ? Text( - localView == ConstructTypeEnum.morph - ? ConstructTypeEnum.morph.indicator.tooltip(context) - : ConstructTypeEnum.vocab.indicator.tooltip(context), + return Scaffold( + appBar: widget.showAppBar + ? AppBar( + title: kIsWeb + ? Text( + localView == ConstructTypeEnum.morph + ? ConstructTypeEnum.morph.indicator.tooltip(context) + : ConstructTypeEnum.vocab.indicator.tooltip(context), + ) + : null, + leading: widget.backButtonOverride ?? + IconButton( + icon: localConstructZoom == null + ? const Icon(Icons.close) + : const Icon(Icons.arrow_back), + onPressed: localConstructZoom == null + ? () => Navigator.of(context).pop() + : () => setConstructZoom(null), + ), + actions: [ + TextButton.icon( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + backgroundColor: localView == ConstructTypeEnum.vocab + ? Theme.of(context).colorScheme.primary.withAlpha(50) + : Theme.of(context).colorScheme.surface, + ), + label: Text(L10n.of(context).vocab), + icon: const Icon(Symbols.dictionary), + onPressed: () => setState(() { + localView = ConstructTypeEnum.vocab; + localConstructZoom = null; + }), + ), + const SizedBox(width: 4.0), + TextButton.icon( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + backgroundColor: localView == ConstructTypeEnum.morph + ? Theme.of(context).colorScheme.primary.withAlpha(50) + : Theme.of(context).colorScheme.surface, + ), + label: Text(L10n.of(context).grammar), + icon: const Icon(Symbols.toys_and_games), + onPressed: () => setState(() { + localView = ConstructTypeEnum.morph; + localConstructZoom = null; + }), + ), + const SizedBox(width: 4.0), + if (kIsWeb) const DownloadAnalyticsButton(), + if (kIsWeb) const SizedBox(width: 4.0), + ], + ) + : localConstructZoom != null + ? AppBar( + leading: widget.backButtonOverride ?? + (localConstructZoom != null + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => setConstructZoom(null), + ) + : const SizedBox()), ) : null, - leading: widget.backButtonOverride ?? - IconButton( - icon: localConstructZoom == null - ? const Icon(Icons.close) - : const Icon(Icons.arrow_back), - onPressed: localConstructZoom == null - ? () => Navigator.of(context).pop() - : () => setConstructZoom(null), - ), - actions: [ - TextButton.icon( - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - backgroundColor: localView == ConstructTypeEnum.vocab - ? Theme.of(context).colorScheme.primary.withAlpha(50) - : Theme.of(context).colorScheme.surface, - ), - label: Text(L10n.of(context).vocab), - icon: const Icon(Symbols.dictionary), - onPressed: () => setState(() { - localView = ConstructTypeEnum.vocab; - localConstructZoom = null; - }), - ), - const SizedBox(width: 4.0), - TextButton.icon( - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - backgroundColor: localView == ConstructTypeEnum.morph - ? Theme.of(context).colorScheme.primary.withAlpha(50) - : Theme.of(context).colorScheme.surface, - ), - label: Text(L10n.of(context).grammar), - icon: const Icon(Symbols.toys_and_games), - onPressed: () => setState(() { - localView = ConstructTypeEnum.morph; - localConstructZoom = null; - }), - ), - const SizedBox(width: 4.0), - if (kIsWeb) const DownloadAnalyticsButton(), - if (kIsWeb) const SizedBox(width: 4.0), - ], - ), - body: localView == ConstructTypeEnum.morph - ? localConstructZoom == null - ? MorphAnalyticsListView(controller: this) - : MorphDetailsView(constructId: localConstructZoom!) - : localConstructZoom == null - ? VocabAnalyticsListView(controller: this) - : VocabDetailsView(constructId: localConstructZoom!), - ), - maxWidth: 600, - maxHeight: 800, + body: localView == ConstructTypeEnum.morph + ? localConstructZoom == null + ? MorphAnalyticsListView(controller: this) + : MorphDetailsView(constructId: localConstructZoom!) + : localConstructZoom == null + ? VocabAnalyticsListView(controller: this) + : VocabDetailsView(constructId: localConstructZoom!), ); } } diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index 7d5ccde38..fc37b953b 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -199,8 +199,8 @@ class MorphTagChip extends StatelessWidget { begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ - Colors.transparent, constructAnalytics.lemmaCategory.color(context), + Colors.transparent, ], ) : null, @@ -218,27 +218,39 @@ class MorphTagChip extends StatelessWidget { width: 28.0, height: 28.0, child: unlocked - ? MorphIcon( - morphFeature: feature, - morphTag: morphTag, + ? IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.surface.withAlpha(180), + ), + padding: const EdgeInsets.all(4), + onPressed: () => {}, + icon: MorphIcon( + morphFeature: feature, + morphTag: morphTag, + ), ) : const Icon( Icons.lock, color: Colors.white, ), ), - Text( - getGrammarCopy( - category: morphFeature, - lemma: morphTag, - context: context, - ) ?? - morphTag, - style: TextStyle( - fontWeight: FontWeight.bold, - color: theme.brightness == Brightness.dark - ? Colors.white - : Colors.black, + Flexible( + child: Text( + getGrammarCopy( + category: morphFeature, + lemma: morphTag, + context: context, + ) ?? + morphTag, + style: TextStyle( + fontWeight: FontWeight.bold, + color: theme.brightness == Brightness.dark + ? Colors.white + : Colors.black, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart index c1e00254a..7db44d463 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_tile.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; @@ -78,92 +79,82 @@ class VocabAnalyticsListView extends StatelessWidget { ), ); - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.analyticsVocabList, + return Column( + children: [ + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.analyticsVocabList, + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: EdgeInsets.symmetric( + horizontal: controller.isSearching ? 8.0 : 24.0, ), - AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - padding: EdgeInsets.symmetric( - horizontal: controller.isSearching ? 8.0 : 24.0, - ), - child: Container( - height: 60, - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 250.0), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: child, - ), - child: controller.isSearching - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - key: const ValueKey('search'), - children: [ - Expanded( - child: TextField( - autofocus: true, - controller: controller.searchController, - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 12.0, - ), - isDense: true, - border: OutlineInputBorder(), - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: controller.toggleSearching, - ), - ], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - key: const ValueKey('filters'), - children: filters, + child: Container( + height: 60, + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: controller.isSearching + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + key: const ValueKey('search'), + children: [ + Expanded( + child: TextField( + autofocus: true, + controller: controller.searchController, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric( + vertical: 6.0, + horizontal: 12.0, + ), + isDense: true, + border: OutlineInputBorder(), ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: controller.toggleSearching, + ), + ], + ) + : Row( + spacing: FluffyThemes.isColumnMode(context) ? 16.0 : 4.0, + mainAxisAlignment: MainAxisAlignment.center, + key: const ValueKey('filters'), + children: filters, ), - ), - ], - ), ), ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 100.0, - mainAxisExtent: 100.0, - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, - ), - itemCount: _filteredVocab.length, - itemBuilder: (context, index) { - final vocabItem = _filteredVocab[index]; - return VocabAnalyticsListTile( - onTap: () => controller.setConstructZoom(vocabItem.id), - constructUse: vocabItem, - ); - }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 100.0, + mainAxisExtent: 100.0, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, ), + itemCount: _filteredVocab.length, + itemBuilder: (context, index) { + final vocabItem = _filteredVocab[index]; + return VocabAnalyticsListTile( + onTap: () => controller.setConstructZoom(vocabItem.id), + constructUse: vocabItem, + ); + }, ), ), - ], - ), + ), + ], ); } } diff --git a/lib/pangea/analytics_misc/construct_list_model.dart b/lib/pangea/analytics_misc/construct_list_model.dart index 86a54e963..a7326e26f 100644 --- a/lib/pangea/analytics_misc/construct_list_model.dart +++ b/lib/pangea/analytics_misc/construct_list_model.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; @@ -35,7 +36,8 @@ class ConstructListModel { /// [D] is the "compression factor". It determines how quickly /// or slowly the level grows relative to XP - final double D = 1500; + + final double D = Environment.isStagingEnvironment ? 500 : 1500; List unlockedLemmas( ConstructTypeEnum type, { diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index ef29da935..741480bca 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -66,6 +66,9 @@ enum ConstructUseTypeEnum { incMM, ignMM, + /// lemma collected by clicking on it + click, + /// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client nan } @@ -135,6 +138,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseIncMmDesc; case ConstructUseTypeEnum.ignMM: return L10n.of(context).constructUseIgnMmDesc; + case ConstructUseTypeEnum.click: + return L10n.of(context).constructUseCollected; case ConstructUseTypeEnum.nan: return L10n.of(context).constructUseNanDesc; } @@ -185,6 +190,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.unk: case ConstructUseTypeEnum.nan: return Icons.help; + case ConstructUseTypeEnum.click: + return Icons.format_color_text; } } @@ -211,6 +218,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corIGC: case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.click: return 2; case ConstructUseTypeEnum.corIt: @@ -279,6 +287,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incMM: case ConstructUseTypeEnum.ignMM: case ConstructUseTypeEnum.em: + case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.nan: return false; } @@ -318,6 +327,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incMM: case ConstructUseTypeEnum.ignMM: case ConstructUseTypeEnum.em: + case ConstructUseTypeEnum.click: return LearningSkillsEnum.reading; case ConstructUseTypeEnum.pvm: return LearningSkillsEnum.speaking; @@ -364,6 +374,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignL: case ConstructUseTypeEnum.ignM: case ConstructUseTypeEnum.ignMM: + case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.nan: return null; } diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index 5f8997a7d..4a5b26817 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; /// A minimized version of AnalyticsController that get the logged in user's analytics class GetAnalyticsController extends BaseController { @@ -73,6 +74,7 @@ class GetAnalyticsController extends BaseController { _initializing = true; try { + await GetStorage.init("analytics_storage"); _client.updateAnalyticsRoomVisibility(); _client.addAnalyticsRoomsToSpaces(); @@ -455,10 +457,13 @@ class GetAnalyticsController extends BaseController { // int diffXP = maxXP - minXP; // if (diffXP < 0) diffXP = 0; - Future getConstructSummaryFromStateEvent() async { + ConstructSummary? getConstructSummaryFromStateEvent() { try { final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - if (analyticsRoom == null) return null; + if (analyticsRoom == null) { + debugPrint("Analytics room is null"); + return null; + } final state = analyticsRoom.getState(PangeaEventTypes.constructSummary, ''); if (state == null) return null; @@ -470,96 +475,87 @@ class GetAnalyticsController extends BaseController { } } - Future generateLevelUpAnalytics( + Future generateLevelUpAnalytics( final int lowerLevel, final int upperLevel, ) async { - // generate level up analytics as a construct summary - ConstructSummary summary; - try { - final int minXP = constructListModel.calculateXpWithLevel(upperLevel); - final int maxXP = constructListModel.calculateXpWithLevel(lowerLevel); - int diffXP = maxXP - minXP; - if (diffXP < 0) diffXP = 0; + final int maxXP = constructListModel.calculateXpWithLevel(upperLevel); + final int minXP = constructListModel.calculateXpWithLevel(lowerLevel); + int diffXP = maxXP - minXP; + if (diffXP < 0) diffXP = 0; - // compute construct use of current level - final List constructUseOfCurrentLevel = []; - int score = 0; - for (final use in constructListModel.uses) { - constructUseOfCurrentLevel.add(use); - score += use.xp; - if (score >= diffXP) break; - } + // compute construct use of current level + final List constructUseOfCurrentLevel = []; + int score = 0; + for (final use in constructListModel.uses) { + constructUseOfCurrentLevel.add(use); + score += use.xp; + if (score >= diffXP) break; + } - // extract construct use message bodies for analytics - final Map> useEventIds = {}; - for (final use in constructUseOfCurrentLevel) { - if (use.metadata.roomId == null) continue; - if (use.metadata.eventId == null) continue; - useEventIds[use.metadata.roomId!] ??= {}; - useEventIds[use.metadata.roomId!]!.add(use.metadata.eventId!); - } + // extract construct use message bodies for analytics + final Map> useEventIds = {}; + for (final use in constructUseOfCurrentLevel) { + if (use.metadata.roomId == null) continue; + if (use.metadata.eventId == null) continue; + useEventIds[use.metadata.roomId!] ??= {}; + useEventIds[use.metadata.roomId!]!.add(use.metadata.eventId!); + } - final List constructUseMessageContentBodies = []; - for (final entry in useEventIds.entries) { - final String roomId = entry.key; - final room = _client.getRoomById(roomId); - if (room == null) continue; - final List messageBodies = []; - for (final eventId in entry.value) { - try { - final Event? event = await room.getEventById(eventId); - if (event?.content["body"] is! String) continue; - final String body = event?.content["body"] as String; - if (body.isEmpty) continue; - messageBodies.add(body); - } catch (e, s) { - debugPrint("Error getting event by ID: $e"); - ErrorHandler.logError( - e: e, - s: s, - data: { - 'roomId': roomId, - 'eventId': eventId, - }, - ); - continue; - } + final List constructUseMessageContentBodies = []; + for (final entry in useEventIds.entries) { + final String roomId = entry.key; + final room = _client.getRoomById(roomId); + if (room == null) continue; + final List messageBodies = []; + for (final eventId in entry.value) { + try { + final Event? event = await room.getEventById(eventId); + if (event?.content["body"] is! String) continue; + final String body = event?.content["body"] as String; + if (body.isEmpty) continue; + messageBodies.add(body); + } catch (e, s) { + debugPrint("Error getting event by ID: $e"); + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': roomId, + 'eventId': eventId, + }, + ); + continue; } - constructUseMessageContentBodies.addAll(messageBodies); } - - final request = ConstructSummaryRequest( - constructs: constructUseOfCurrentLevel, - constructUseMessageContentBodies: constructUseMessageContentBodies, - language: _l1!.langCodeShort, - upperLevel: upperLevel, - lowerLevel: lowerLevel, - ); - - final response = await ConstructRepo.generateConstructSummary(request); - summary = response.summary; - } catch (e) { - debugPrint("Error generating level up analytics: $e"); - ErrorHandler.logError(e: e, data: {'e': e}); - return null; + constructUseMessageContentBodies.addAll(messageBodies); } - try { - final Room? analyticsRoom = await _client.getMyAnalyticsRoom(_l2!); - if (analyticsRoom == null) { - throw "Analytics room not found for user"; - } + final request = ConstructSummaryRequest( + constructs: constructUseOfCurrentLevel, + constructUseMessageContentBodies: constructUseMessageContentBodies, + language: _l1!.langCodeShort, + upperLevel: upperLevel, + lowerLevel: lowerLevel, + ); - // don't await this, just return the original response - _saveConstructSummaryResponseToStateEvent( - summary, - ); - } catch (e, s) { - debugPrint("Error saving construct summary room: $e"); - ErrorHandler.logError(e: e, s: s, data: {'e': e}); + final response = await ConstructRepo.generateConstructSummary(request); + final ConstructSummary summary = response.summary; + summary.levelVocabConstructs = MatrixState + .pangeaController.getAnalytics.constructListModel.vocabLemmas; + summary.levelGrammarConstructs = MatrixState + .pangeaController.getAnalytics.constructListModel.grammarLemmas; + + final Room? analyticsRoom = await _client.getMyAnalyticsRoom(_l2!); + if (analyticsRoom == null) { + throw "Analytics room not found for user"; } + // don't await this, just return the original response + _saveConstructSummaryResponseToStateEvent( + summary, + ); + return summary; } } diff --git a/lib/pangea/analytics_misc/level_up.dart b/lib/pangea/analytics_misc/level_up.dart deleted file mode 100644 index 6e348aa13..000000000 --- a/lib/pangea/analytics_misc/level_up.dart +++ /dev/null @@ -1,557 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:audioplayers/audioplayers.dart'; -import 'package:cached_network_image/cached_network_image.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; -import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; -import 'package:fluffychat/pangea/common/utils/overlay.dart'; -import 'package:fluffychat/pangea/constructs/construct_repo.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class LevelUpConstants { - static const String starFileName = "star.png"; - static const String dinoLevelUPFileName = "DinoBot-Congratulate.png"; -} - -class LevelUpUtil { - static Future showLevelUpDialog( - int level, - int prevLevel, - BuildContext context, - ) async { - final player = AudioPlayer(); - - final snackbarRegex = RegExp(r'_snackbar$'); - - while (MatrixState.pAnyState.activeOverlays - .any((overlayId) => snackbarRegex.hasMatch(overlayId))) { - await Future.delayed(const Duration(milliseconds: 100)); - } - - player - .play( - UrlSource( - "${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpAudioFileName}", - ), - ) - .then( - (_) => Future.delayed( - const Duration(seconds: 2), - () => player.dispose(), - ), - ); - - OverlayUtil.showOverlay( - overlayKey: "level_up_notification", - context: context, - child: LevelUpBanner( - level: level, - prevLevel: prevLevel, - ), - transformTargetId: '', - position: OverlayPositionEnum.top, - backDropToDismiss: false, - closePrevOverlay: false, - canPop: false, - ); - } -} - -class LevelUpBanner extends StatefulWidget { - final int level; - final int prevLevel; - - const LevelUpBanner({ - required this.level, - required this.prevLevel, - super.key, - }); - - @override - LevelUpBannerState createState() => LevelUpBannerState(); -} - -class LevelUpBannerState extends State - with TickerProviderStateMixin { - late AnimationController _slideController; - late Animation _slideAnimation; - - late AnimationController _sizeController; - late Animation _sizeAnimation; - - bool _showDetails = false; - bool _showedDetails = false; - - ConstructSummary? _constructSummary; - String? _error; - bool _loading = true; - - @override - void initState() { - super.initState(); - _setConstructSummary(); - - _slideController = AnimationController( - vsync: this, - duration: FluffyThemes.animationDuration, - ); - - _slideAnimation = Tween( - begin: const Offset(0, -1), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: _slideController, - curve: Curves.easeOut, - ), - ); - - _sizeController = AnimationController( - vsync: this, - duration: FluffyThemes.animationDuration, - ); - - _sizeAnimation = Tween( - begin: 0, - end: 1, - ).animate( - CurvedAnimation( - parent: _sizeController, - curve: Curves.easeOut, - ), - ); - - _slideController.forward(); - - Future.delayed(const Duration(seconds: 15), () async { - if (mounted && !_showedDetails) _close(); - }); - } - - @override - void dispose() { - _slideController.dispose(); - _sizeController.dispose(); - super.dispose(); - } - - Future _setConstructSummary() async { - try { - setState(() => _loading = true); - _constructSummary = await MatrixState.pangeaController.getAnalytics - .generateLevelUpAnalytics( - widget.level, - widget.prevLevel, - ); - } catch (e) { - _error = e.toString(); - } finally { - if (mounted) { - setState(() => _loading = false); - } - } - } - - Future _close() async { - await _slideController.reverse(); - MatrixState.pAnyState.closeOverlay("level_up_notification"); - } - - int _skillsPoints(LearningSkillsEnum skill) { - switch (skill) { - case LearningSkillsEnum.writing: - return _constructSummary?.writingConstructScore ?? 0; - case LearningSkillsEnum.reading: - return _constructSummary?.readingConstructScore ?? 0; - case LearningSkillsEnum.speaking: - return _constructSummary?.speakingConstructScore ?? 0; - case LearningSkillsEnum.hearing: - return _constructSummary?.hearingConstructScore ?? 0; - default: - return 0; - } - } - - Future _toggleDetails() async { - if (!Environment.isStagingEnvironment) return; - - FocusScope.of(context).unfocus(); - - if (mounted) { - setState(() { - _showDetails = !_showDetails; - if (_showDetails && !_showedDetails) { - _showedDetails = true; - } - }); - - await (_showDetails - ? _sizeController.forward() - : _sizeController.reverse()); - - if (!_showDetails) { - await Future.delayed( - const Duration(milliseconds: 300), - () async { - if (!mounted) return; - _close(); - }, - ); - } - } - } - - @override - Widget build(BuildContext context) { - final style = FluffyThemes.isColumnMode(context) - ? Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ) - : Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ); - - return SafeArea( - child: Material( - color: Colors.transparent, - child: Stack( - children: [ - SlideTransition( - position: _slideAnimation, - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 600.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onPanUpdate: (details) { - if (details.delta.dy < -10) _close(); - }, - onTap: _toggleDetails, - child: Container( - margin: const EdgeInsets.only( - top: 16, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 24, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8.0, - children: [ - Flexible( - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: L10n.of(context) - .congratulationsOnReaching( - widget.level, - ), - style: style, - ), - TextSpan( - text: " ", - style: style, - ), - WidgetSpan( - child: CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}", - height: 24, - width: 24, - ), - ), - ], - ), - ), - ), - Row( - children: [ - if (Environment.isStagingEnvironment) - AnimatedSize( - duration: FluffyThemes.animationDuration, - child: _error == null - ? FluffyThemes.isColumnMode(context) - ? ElevatedButton( - style: IconButton.styleFrom( - padding: const EdgeInsets - .symmetric( - vertical: 4.0, - horizontal: 16.0, - ), - ), - onPressed: _toggleDetails, - child: Text( - L10n.of(context).details, - ), - ) - : SizedBox( - width: 32.0, - height: 32.0, - child: Center( - child: IconButton( - icon: const Icon( - Icons.info_outline, - ), - style: - IconButton.styleFrom( - padding: - const EdgeInsets - .all( - 4.0, - ), - ), - onPressed: _toggleDetails, - constraints: - const BoxConstraints(), - ), - ), - ) - : Row( - children: [ - Tooltip( - message: L10n.of(context) - .oopsSomethingWentWrong, - child: Icon( - Icons.error, - color: Theme.of(context) - .colorScheme - .error, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: _close, - ), - ], - ), - ], - ), - ), - ), - SizeTransition( - sizeFactor: _sizeAnimation, - child: Container( - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * 0.75, - ), - margin: const EdgeInsets.only( - top: 4.0, - ), - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(16), - child: _loading - ? const Center( - child: CircularProgressIndicator.adaptive(), - ) - : _error != null - ? Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.error, - color: Theme.of(context) - .colorScheme - .error, - ), - const SizedBox(width: 8.0), - Text( - L10n.of(context) - .oopsSomethingWentWrong, - ), - ], - ) - : SingleChildScrollView( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.center, - spacing: 24.0, - children: [ - Table( - columnWidths: const { - 0: IntrinsicColumnWidth(), - 1: FlexColumnWidth(), - 2: IntrinsicColumnWidth(), - }, - defaultVerticalAlignment: - TableCellVerticalAlignment - .middle, - children: [ - ...LearningSkillsEnum.values - .where( - (v) => - v.isVisible && - _skillsPoints(v) > -1, - ) - .map((skill) { - return TableRow( - children: [ - Padding( - padding: const EdgeInsets - .symmetric( - vertical: 9.0, - horizontal: 18.0, - ), - child: Icon( - skill.icon, - size: 25, - color: Colors.white, - ), - ), - Padding( - padding: const EdgeInsets - .symmetric( - vertical: 9.0, - horizontal: 18.0, - ), - child: Text( - skill.tooltip(context), - style: const TextStyle( - fontSize: 16, - fontWeight: - FontWeight.w600, - color: Colors.white, - ), - textAlign: - TextAlign.center, - ), - ), - Padding( - padding: const EdgeInsets - .symmetric( - vertical: 9.0, - horizontal: 18.0, - ), - child: Text( - "+ ${_skillsPoints(skill)} XP", - style: const TextStyle( - fontSize: 16, - fontWeight: - FontWeight.w600, - color: Colors.white, - ), - textAlign: - TextAlign.center, - ), - ), - ], - ); - }), - ], - ), - CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${LevelUpConstants.dinoLevelUPFileName}", - width: 400, - fit: BoxFit.cover, - ), - if (_constructSummary?.textSummary != - null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .secondaryContainer, - borderRadius: - BorderRadius.circular(8), - ), - child: Text( - _constructSummary!.textSummary, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSecondaryContainer, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox( - height: 24, - ), - // Share button, currently no functionality - // ElevatedButton( - // onPressed: () { - // // Add share functionality - // }, - // style: ElevatedButton.styleFrom( - // backgroundColor: Colors.white, - // foregroundColor: Colors.black, - // padding: const EdgeInsets.symmetric( - // vertical: 12, - // horizontal: 24, - // ), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(8), - // ), - // ), - // child: const Row( - // mainAxisSize: MainAxisSize - // .min, - // children: [ - // Text( - // "Share with Friends", - // style: TextStyle( - // fontSize: 16, - // fontWeight: FontWeight.bold, - // ), - // ), - // SizedBox( - // width: 8, - // ), - // Icon( - // Icons.ios_share, - // size: 20, - // ), - // ), - // ), - // ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pangea/analytics_misc/level_up/level_up_banner.dart b/lib/pangea/analytics_misc/level_up/level_up_banner.dart new file mode 100644 index 000000000..467a3f0bf --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/level_up_banner.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_popup.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LevelUpConstants { + static const String starFileName = "star.png"; + static const String dinoLevelUPFileName = "DinoBot-Congratulate.png"; +} + +class LevelUpUtil { + static Future showLevelUpDialog( + int level, + int prevLevel, + BuildContext context, + ) async { + // Remove delay since GetAnalyticsController._onLevelUp is already async + final player = AudioPlayer(); + + // Wait for any existing snackbars to dismiss + await _waitForSnackbars(context); + + await player.play( + UrlSource( + "${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpAudioFileName}", + ), + ); + + if (!context.mounted) return; + + await OverlayUtil.showOverlay( + overlayKey: "level_up_notification", + context: context, + child: LevelUpBanner( + level: level, + prevLevel: prevLevel, + backButtonOverride: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + MatrixState.pAnyState.closeOverlay("level_up_notification"); + }, + ), + ), + transformTargetId: '', + position: OverlayPositionEnum.top, + backDropToDismiss: false, + closePrevOverlay: false, + canPop: false, + ); + + await Future.delayed(const Duration(seconds: 2)); + player.dispose(); + } + + static Future _waitForSnackbars(BuildContext context) async { + final snackbarRegex = RegExp(r'_snackbar$'); + while (MatrixState.pAnyState.activeOverlays + .any((id) => snackbarRegex.hasMatch(id))) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } +} + +class LevelUpBanner extends StatefulWidget { + final int level; + final int prevLevel; + final Widget? backButtonOverride; + + const LevelUpBanner({ + required this.level, + required this.prevLevel, + required this.backButtonOverride, + super.key, + }); + + @override + LevelUpBannerState createState() => LevelUpBannerState(); +} + +class LevelUpBannerState extends State + with TickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + + bool _showedDetails = false; + + final Completer _constructSummaryCompleter = + Completer(); + + @override + void initState() { + super.initState(); + + _loadConstructSummary(); + + LevelUpManager.instance.preloadAnalytics( + context, + widget.level, + widget.prevLevel, + ); + + _slideController = AnimationController( + vsync: this, + duration: FluffyThemes.animationDuration, + ); + + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _slideController, + curve: Curves.easeOut, + ), + ); + + _slideController.forward(); + + Future.delayed(const Duration(seconds: 10), () async { + if (mounted && !_showedDetails) { + _close(); + } + }); + } + + Future _close() async { + await _slideController.reverse(); + MatrixState.pAnyState.closeOverlay("level_up_notification"); + } + + @override + void dispose() { + _slideController.dispose(); + super.dispose(); + } + + Future _toggleDetails() async { + await _close(); + LevelUpManager.instance.markPopupSeen(); + _showedDetails = true; + + FocusScope.of(context).unfocus(); + + await showDialog( + context: context, + builder: (context) => LevelUpPopup( + constructSummaryCompleter: _constructSummaryCompleter, + ), + ); + } + + Future _loadConstructSummary() async { + try { + final summary = MatrixState.pangeaController.getAnalytics + .generateLevelUpAnalytics(widget.prevLevel, widget.level); + _constructSummaryCompleter.complete(summary); + } catch (e) { + debugPrint("Error generating level up analytics: $e"); + _constructSummaryCompleter.completeError(e); + } + } + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + + final style = isColumnMode + ? Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppConfig.gold, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ) + : Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppConfig.gold, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ); + + return SafeArea( + child: Material( + type: MaterialType.transparency, + child: SlideTransition( + position: _slideAnimation, + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onPanUpdate: (details) { + if (details.delta.dy < -10) _close(); + }, + onTap: _toggleDetails, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 4.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + bottom: BorderSide( + color: AppConfig.gold.withAlpha(200), + width: 2.0, + ), + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(AppConfig.borderRadius), + bottomRight: Radius.circular(AppConfig.borderRadius), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Spacer for symmetry + SizedBox( + width: constraints.maxWidth >= 600 ? 120.0 : 65.0, + ), + // Centered content + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isColumnMode ? 16.0 : 8.0, + ), + child: Wrap( + spacing: 16.0, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + "Level up", + style: style, + overflow: TextOverflow.ellipsis, + ), + CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}", + height: 24, + width: 24, + ), + ], + ), + ), + ), + SizedBox( + width: constraints.maxWidth >= 600 ? 120.0 : 65.0, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (Environment.isStagingEnvironment) + SizedBox( + width: 32.0, + height: 32.0, + child: Center( + child: IconButton( + icon: const Icon(Icons.arrow_drop_down), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4.0), + ), + onPressed: _toggleDetails, + constraints: const BoxConstraints(), + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pangea/analytics_misc/level_up/level_up_manager.dart b/lib/pangea/analytics_misc/level_up/level_up_manager.dart new file mode 100644 index 000000000..cec323475 --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/level_up_manager.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LevelUpManager { + // Singleton instance so analytics can be generated when level up is initiated, and be ready by the time user clicks on banner + static final LevelUpManager instance = LevelUpManager._internal(); + + LevelUpManager._internal(); + + int prevLevel = 0; + int level = 0; + + int prevGrammar = 0; + int nextGrammar = 0; + int prevVocab = 0; + int nextVocab = 0; + + bool hasSeenPopup = false; + bool shouldAutoPopup = false; + + Future preloadAnalytics( + BuildContext context, + int level, + int prevLevel, + ) async { + this.level = level; + this.prevLevel = prevLevel; + + //For on route change behavior, if added in the future + shouldAutoPopup = true; + + nextGrammar = MatrixState + .pangeaController.getAnalytics.constructListModel.grammarLemmas; + nextVocab = MatrixState + .pangeaController.getAnalytics.constructListModel.vocabLemmas; + + final LanguageModel? l2 = + MatrixState.pangeaController.languageController.userL2; + final Room? analyticsRoom = + MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2!); + + if (analyticsRoom != null) { + // How to get all summary events in the timeline + final timeline = await analyticsRoom.getTimeline(); + final summaryEvents = timeline.events + .where( + (e) => e.type == PangeaEventTypes.constructSummary, + ) + .map( + (e) => ConstructSummary.fromJson(e.content), + ) + .toList(); + + //Find previous summary to get grammar constructs and vocab numbers from + final lastSummary = summaryEvents + .where((summary) => summary.upperLevel == prevLevel) + .toList() + .isNotEmpty + ? summaryEvents + .firstWhere((summary) => summary.upperLevel == prevLevel) + : null; + + //Set grammar and vocab from last level summary, if there is one. Otherwise set to placeholder data + if (lastSummary != null && + lastSummary.levelVocabConstructs != null && + lastSummary.levelGrammarConstructs != null) { + prevVocab = lastSummary.levelVocabConstructs!; + prevGrammar = lastSummary.levelGrammarConstructs!; + } else { + prevGrammar = (nextGrammar / prevLevel) as int; + prevVocab = (nextVocab / prevLevel) as int; + } + } + } + + void markPopupSeen() { + hasSeenPopup = true; + shouldAutoPopup = false; + } + + void reset() { + hasSeenPopup = false; + shouldAutoPopup = false; + prevLevel = 0; + level = 0; + prevGrammar = 0; + nextGrammar = 0; + prevVocab = 0; + nextVocab = 0; + } +} diff --git a/lib/pangea/analytics_misc/level_up/level_up_popup.dart b/lib/pangea/analytics_misc/level_up/level_up_popup.dart new file mode 100644 index 000000000..795700652 --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/level_up_popup.dart @@ -0,0 +1,533 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:animated_flip_counter/animated_flip_counter.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:confetti/confetti.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix_api_lite/generated/model.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/rain_confetti.dart'; +import 'package:fluffychat/pangea/analytics_summary/progress_bar/level_bar.dart'; +import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; + +class LevelUpPopup extends StatelessWidget { + final Completer constructSummaryCompleter; + const LevelUpPopup({ + required this.constructSummaryCompleter, + super.key, + }); + + @override + Widget build(BuildContext context) { + return FullWidthDialog( + maxWidth: 400, + maxHeight: 800, + dialogContent: Scaffold( + appBar: AppBar( + centerTitle: true, + title: kIsWeb + ? Text( + L10n.of(context).youHaveLeveledUp, + style: const TextStyle( + color: AppConfig.gold, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + body: LevelUpPopupContent( + prevLevel: LevelUpManager.instance.prevLevel, + level: LevelUpManager.instance.level, + constructSummaryCompleter: constructSummaryCompleter, + ), + ), + ); + } +} + +class LevelUpPopupContent extends StatefulWidget { + final int prevLevel; + final int level; + final Completer constructSummaryCompleter; + + const LevelUpPopupContent({ + super.key, + required this.prevLevel, + required this.level, + required this.constructSummaryCompleter, + }); + + @override + State createState() => _LevelUpPopupContentState(); +} + +class _LevelUpPopupContentState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final ConfettiController _confettiController; + late final Future profile; + + int displayedLevel = -1; + Uri? avatarUrl; + bool _hasBlastedConfetti = false; + + String language = MatrixState.pangeaController.languageController + .activeL2Code() + ?.toUpperCase() ?? + LanguageKeys.unknownLanguage; + + ConstructSummary? _constructSummary; + Object? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadConstructSummary(); + LevelUpManager.instance.markPopupSeen(); + displayedLevel = widget.prevLevel; + _confettiController = + ConfettiController(duration: const Duration(seconds: 1)); + + final client = Matrix.of(context).client; + client.fetchOwnProfile().then((profile) { + setState(() => avatarUrl = profile.avatarUrl); + }); + + _controller = AnimationController( + duration: const Duration(seconds: 5), + vsync: this, + ); + + // halfway through the animation, switch to the new level + _controller.addListener(() { + if (_controller.value >= 0.5 && displayedLevel == widget.prevLevel) { + setState(() { + displayedLevel = widget.level; + }); + } + }); + + _controller.addListener(() { + if (_controller.value >= 0.5 && !_hasBlastedConfetti) { + _hasBlastedConfetti = true; + rainConfetti(context); + } + }); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + _confettiController.dispose(); + LevelUpManager.instance.reset(); + stopConfetti(); + super.dispose(); + } + + int get _startGrammar => LevelUpManager.instance.prevGrammar; + int get _startVocab => LevelUpManager.instance.prevVocab; + + get _endGrammar => LevelUpManager.instance.nextGrammar; + get _endVocab => LevelUpManager.instance.nextVocab; + + Future _loadConstructSummary() async { + try { + _constructSummary = await widget.constructSummaryCompleter.future; + } catch (e) { + _error = e; + } finally { + setState(() => _loading = false); + } + } + + int _getSkillXP(LearningSkillsEnum skill) { + if (_constructSummary == null) return 0; + return switch (skill) { + LearningSkillsEnum.writing => + _constructSummary?.writingConstructScore ?? 0, + LearningSkillsEnum.reading => + _constructSummary?.readingConstructScore ?? 0, + LearningSkillsEnum.speaking => + _constructSummary?.speakingConstructScore ?? 0, + LearningSkillsEnum.hearing => + _constructSummary?.hearingConstructScore ?? 0, + _ => 0, + }; + } + + @override + @override + Widget build(BuildContext context) { + final Animation progressAnimation = + Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.5)), + ); + + final Animation vocabAnimation = + IntTween(begin: _startVocab, end: _endVocab).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad), + ), + ); + + final Animation grammarAnimation = + IntTween(begin: _startGrammar, end: _endGrammar).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad), + ), + ); + + final Animation skillsOpacity = + Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.7, 1.0, curve: Curves.easeIn), + ), + ); + + final Animation shrinkMultiplier = + Tween(begin: 1.0, end: 0.3).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.7, 1.0, curve: Curves.easeInOut), + ), + ); + + final colorScheme = Theme.of(context).colorScheme; + final grammarVocabStyle = Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ); + final username = + Matrix.of(context).client.userID?.split(':').first.substring(1) ?? ''; + + return Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _controller, + builder: (_, __) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(24.0), + child: avatarUrl == null + ? Avatar( + name: username, + showPresence: false, + size: 150 * shrinkMultiplier.value, + ) + : ClipOval( + child: MxcImage( + uri: avatarUrl, + width: 150 * shrinkMultiplier.value, + height: 150 * shrinkMultiplier.value, + ), + ), + ), + Text( + language, + style: TextStyle( + fontSize: 24 * skillsOpacity.value, + color: AppConfig.goldLight, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // Progress bar + Level + AnimatedBuilder( + animation: _controller, + builder: (_, __) => Row( + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return LevelBar( + details: const LevelBarDetails( + fillColor: AppConfig.goldLight, + currentPoints: 0, + widthMultiplier: 1, + ), + progressBarDetails: ProgressBarDetails( + totalWidth: constraints.maxWidth * + progressAnimation.value, + height: 20, + borderColor: colorScheme.primary, + ), + ); + }, + ), + ), + const SizedBox(width: 8), + Text( + "⭐", + style: Theme.of(context).textTheme.titleLarge, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedFlipCounter( + value: displayedLevel, + textStyle: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppConfig.goldLight, + ), + duration: const Duration(milliseconds: 1000), + curve: Curves.easeInOut, + ), + ), + ], + ), + ), + const SizedBox(height: 25), + + // Vocab and grammar row + AnimatedBuilder( + animation: _controller, + builder: (_, __) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "+ ${_endVocab - _startVocab}", + style: const TextStyle( + color: Colors.lightGreen, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Icon( + Symbols.dictionary, + color: colorScheme.primary, + size: 35, + ), + const SizedBox(width: 8), + Text( + '${vocabAnimation.value}', + style: grammarVocabStyle, + ), + ], + ), + ], + ), + const SizedBox(width: 40), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "+ ${_endGrammar - _startGrammar}", + style: const TextStyle( + color: Colors.lightGreen, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Icon( + Symbols.toys_and_games, + color: colorScheme.primary, + size: 35, + ), + const SizedBox(width: 8), + Text( + '${grammarAnimation.value}', + style: grammarVocabStyle, + ), + ], + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + if (_loading) + const Center( + child: SizedBox( + height: 50, + width: 50, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: AppConfig.goldLight, + ), + ), + ) + else if (_error != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + L10n.of(context).oopsSomethingWentWrong, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 16, + ), + ), + ) + else if (_constructSummary != null) + // Skills section + AnimatedBuilder( + animation: skillsOpacity, + builder: (_, __) => Opacity( + opacity: skillsOpacity.value, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildSkillsTable(context), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + _constructSummary!.textSummary, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${LevelUpConstants.dinoLevelUPFileName}", + width: 400, + fit: BoxFit.cover, + ), + ), + ], + ), + ), + ), + // Share button, currently no functionality + // ElevatedButton( + // onPressed: () { + // // Add share functionality + // }, + // style: ElevatedButton.styleFrom( + // backgroundColor: Colors.white, + // foregroundColor: Colors.black, + // padding: const EdgeInsets.symmetric( + // vertical: 12, + // horizontal: 24, + // ), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(8), + // ), + // ), + // child: const Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Text( + // "Share with Friends", + // style: TextStyle( + // fontSize: 16, + // fontWeight: FontWeight.bold, + // ), + // ), + // SizedBox( + // width: 8, + // ), + // Icon( + // Icons.ios_share, + // size: 20, + // ), + // ], + // ), + // ), + ], + ), + ), + ], + ); + } + + Widget _buildSkillsTable(BuildContext context) { + final visibleSkills = LearningSkillsEnum.values + .where((skill) => (_getSkillXP(skill) > -1) && skill.isVisible) + .toList(); + + const itemsPerRow = 4; + // chunk into rows of up to 4 + final rows = >[ + for (var i = 0; i < visibleSkills.length; i += itemsPerRow) + visibleSkills.sublist( + i, + min(i + itemsPerRow, visibleSkills.length), + ), + ]; + + return Column( + children: rows.map((row) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: row.map((skill) { + return Flexible( + fit: FlexFit.loose, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + skill.tooltip(context), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Icon( + skill.icon, + size: 25, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(height: 4), + Text( + '+ ${_getSkillXP(skill)} XP', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppConfig.gold, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/pangea/analytics_misc/level_up/rain_confetti.dart b/lib/pangea/analytics_misc/level_up/rain_confetti.dart new file mode 100644 index 000000000..4dc7cb968 --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/rain_confetti.dart @@ -0,0 +1,127 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:confetti/confetti.dart'; + +import 'package:fluffychat/config/app_config.dart'; + +OverlayEntry? _confettiEntry; +ConfettiController? _blastController; +ConfettiController? _rainController; + +void rainConfetti(BuildContext context) { + if (_confettiEntry != null) return; // Prevent duplicates + int numParticles = 2; + + _blastController = ConfettiController(duration: const Duration(seconds: 1)); + _rainController = ConfettiController(duration: const Duration(seconds: 8)); + Future.delayed(const Duration(seconds: 4), () { + if (_rainController?.state == ConfettiControllerState.playing) { + numParticles = 1; + } + }); + + _blastController!.play(); + _rainController!.play(); + + final screenWidth = MediaQuery.of(context).size.width; + final isSmallScreen = screenWidth < 600; + final count = isSmallScreen ? 2 : 5; + final spacing = screenWidth / (count + 1); + + _confettiEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + // Initial center blast + Positioned( + top: 0, + left: screenWidth / 2, + child: IgnorePointer( + child: ConfettiWidget( + confettiController: _blastController!, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + emissionFrequency: .02, + numberOfParticles: 40, + minimumSize: const Size(20, 20), + maximumSize: const Size(25, 25), + minBlastForce: 10, + maxBlastForce: 40, + gravity: 0.07, + colors: const [AppConfig.goldLight, AppConfig.gold], + createParticlePath: drawStar, + ), + ), + ), + + // Rain confetti from the top + ...List.generate(count, (index) { + final left = spacing * (index + 1) - 10; + + return Positioned( + top: -30, // Small buffer above top edge + left: left, + child: IgnorePointer( + child: ConfettiWidget( + confettiController: _rainController!, + blastDirectionality: BlastDirectionality.directional, + blastDirection: 3 * pi / 2, + shouldLoop: false, + maxBlastForce: 5, + minBlastForce: 2, + minimumSize: const Size(20, 20), + maximumSize: const Size(25, 25), + gravity: 0.07, + emissionFrequency: 0.1, + numberOfParticles: numParticles, + colors: const [AppConfig.goldLight, AppConfig.gold], + createParticlePath: drawStar, + ), + ), + ); + }), + ], + ), + ); + + Overlay.of(context, rootOverlay: true).insert(_confettiEntry!); +} + +void stopConfetti() { + _confettiEntry?.remove(); + _confettiEntry = null; + + _blastController?.dispose(); + _blastController = null; + + _rainController?.dispose(); + _rainController = null; +} + +Path drawStar(Size size) { + double degToRad(double deg) => deg * (pi / 180.0); + + const numberOfPoints = 5; + final halfWidth = size.width / 2; + final externalRadius = halfWidth; + final internalRadius = halfWidth / 2.5; + final degreesPerStep = degToRad(360 / numberOfPoints); + final halfDegreesPerStep = degreesPerStep / 2; + final path = Path(); + final fullAngle = degToRad(360); + path.moveTo(size.width, halfWidth); + + for (double step = 0; step < fullAngle; step += degreesPerStep) { + path.lineTo( + halfWidth + externalRadius * cos(step), + halfWidth + externalRadius * sin(step), + ); + path.lineTo( + halfWidth + internalRadius * cos(step + halfDegreesPerStep), + halfWidth + internalRadius * sin(step + halfDegreesPerStep), + ); + } + path.close(); + return path; +} diff --git a/lib/pangea/analytics_page/analytics_page.dart b/lib/pangea/analytics_page/analytics_page.dart new file mode 100644 index 000000000..62f3fcf34 --- /dev/null +++ b/lib/pangea/analytics_page/analytics_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_page/analytics_page_view.dart'; +import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; + +class AnalyticsPage extends StatefulWidget { + final ProgressIndicatorEnum? selectedIndicator; + final ConstructIdentifier? constructZoom; + const AnalyticsPage({ + super.key, + this.selectedIndicator, + this.constructZoom, + }); + + @override + AnalyticsPageState createState() => AnalyticsPageState(); +} + +class AnalyticsPageState extends State { + ProgressIndicatorEnum? selectedIndicator = ProgressIndicatorEnum.wordsUsed; + + @override + void initState() { + super.initState(); + selectedIndicator = widget.selectedIndicator ?? + ProgressIndicatorEnum.wordsUsed; // Default to wordsUsed if not set + } + + @override + void didUpdateWidget(covariant AnalyticsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedIndicator != widget.selectedIndicator && + widget.selectedIndicator != null) { + setState( + () => selectedIndicator = widget.selectedIndicator!, + ); // Update to new value + } + } + + @override + Widget build(BuildContext context) => AnalyticsPageView(controller: this); +} diff --git a/lib/pangea/analytics_page/analytics_page_constants.dart b/lib/pangea/analytics_page/analytics_page_constants.dart new file mode 100644 index 000000000..075bde772 --- /dev/null +++ b/lib/pangea/analytics_page/analytics_page_constants.dart @@ -0,0 +1,3 @@ +class AnalyticsPageConstants { + static const String dinoBotFileName = 'Analytic_DinoBot.png'; +} diff --git a/lib/pangea/analytics_page/analytics_page_view.dart b/lib/pangea/analytics_page/analytics_page_view.dart new file mode 100644 index 000000000..458343305 --- /dev/null +++ b/lib/pangea/analytics_page/analytics_page_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page.dart'; +import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; +import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart'; +import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/widgets/navigation_rail.dart'; + +class AnalyticsPageView extends StatelessWidget { + final AnalyticsPageState controller; + const AnalyticsPageView({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Row( + children: [ + if (!isColumnMode && AppConfig.displayNavigationRail) ...[ + SpacesNavigationRail( + activeSpaceId: null, + onGoToChats: () => context.go('/rooms'), + onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'), + ), + Container( + color: Theme.of(context).dividerColor, + width: 1, + ), + ], + Expanded( + child: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsetsGeometry.all(16.0), + child: Column( + spacing: 16.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LearningProgressIndicators( + selected: controller.selectedIndicator, + ), + Expanded( + child: Builder( + builder: (context) { + if (controller.selectedIndicator == + ProgressIndicatorEnum.level) { + return const LevelDialogContent(); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.morphsUsed) { + return AnalyticsPopupWrapper( + constructZoom: controller.widget.constructZoom, + view: ConstructTypeEnum.morph, + showAppBar: false, + ); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.wordsUsed) { + return AnalyticsPopupWrapper( + constructZoom: controller.widget.constructZoom, + view: ConstructTypeEnum.vocab, + showAppBar: false, + ); + } + + return const SizedBox(); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pangea/analytics_summary/learning_progress_indicator_button.dart b/lib/pangea/analytics_summary/learning_progress_indicator_button.dart index 30ea42199..1e4632e0b 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicator_button.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicator_button.dart @@ -7,6 +7,7 @@ class HoverButton extends StatelessWidget { final Widget child; final BorderRadius? borderRadius; final double hoverOpacity; + final bool selected; const HoverButton({ super.key, @@ -14,6 +15,7 @@ class HoverButton extends StatelessWidget { required this.child, this.borderRadius, this.hoverOpacity = 0.2, + this.selected = false, }); @override @@ -26,7 +28,7 @@ class HoverButton extends StatelessWidget { onTap: onPressed, child: Container( decoration: BoxDecoration( - color: hovered + color: hovered || selected ? Theme.of(context) .colorScheme .primary diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index d7a6d1c98..6c0002db0 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; +import 'package:go_router/go_router.dart'; + import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicator_button.dart'; -import 'package:fluffychat/pangea/analytics_summary/level_bar_popup.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicator.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; @@ -19,7 +19,11 @@ import 'package:fluffychat/widgets/matrix.dart'; /// messages sent, words used, and error types, which can /// be clicked to access more fine-grained analytics data. class LearningProgressIndicators extends StatefulWidget { - const LearningProgressIndicators({super.key}); + final ProgressIndicatorEnum? selected; + const LearningProgressIndicators({ + super.key, + this.selected, + }); @override State createState() => @@ -106,12 +110,10 @@ class LearningProgressIndicatorsState children: ConstructTypeEnum.values .map( (c) => HoverButton( + selected: widget.selected == c.indicator, onPressed: () { - showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - view: c, - ), + context.go( + "/rooms/analytics?mode=${c.indicator.toShortString()}", ); }, child: ProgressIndicatorBadge( @@ -168,10 +170,7 @@ class LearningProgressIndicatorsState cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { - showDialog( - context: context, - builder: (c) => const LevelBarPopup(), - ); + context.go("/rooms/analytics?mode=level"); }, child: Row( spacing: 8.0, diff --git a/lib/pangea/analytics_summary/level_bar_popup.dart b/lib/pangea/analytics_summary/level_bar_popup.dart index f7bf790ab..2d7af94d9 100644 --- a/lib/pangea/analytics_summary/level_bar_popup.dart +++ b/lib/pangea/analytics_summary/level_bar_popup.dart @@ -1,28 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; -import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart'; class LevelBarPopup extends StatelessWidget { const LevelBarPopup({ super.key, }); - GetAnalyticsController get getAnalyticsController => - MatrixState.pangeaController.getAnalytics; - int get level => getAnalyticsController.constructListModel.level; - int get totalXP => getAnalyticsController.constructListModel.totalXP; - int get maxLevelXP => getAnalyticsController.minXPForNextLevel; - List get uses => - getAnalyticsController.constructListModel.truncatedUses; - @override Widget build(BuildContext context) { return Dialog( @@ -33,143 +17,7 @@ class LevelBarPopup extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(20.0), - child: Scaffold( - appBar: AppBar( - titleSpacing: 0, - automaticallyImplyLeading: false, - title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "⭐ ${L10n.of(context).levelShort(level)}", - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w900, - color: AppConfig.gold, - ), - ), - Opacity( - opacity: 0.25, - child: Text( - L10n.of(context).levelShort(level + 1), - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w900, - ), - ), - ), - ], - ), - ), - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LearningProgressBar( - height: 24, - level: level, - totalXP: totalXP, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - child: Text( - L10n.of(context).xpIntoLevel(totalXP, maxLevelXP), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: AppConfig.gold, - ), - ), - ), - const Divider(), - ], - ), - ), - Expanded( - child: ListView.builder( - itemCount: uses.length, - itemBuilder: (context, index) { - final use = uses[index]; - String lemmaCopy = use.lemma; - if (use.constructType == ConstructTypeEnum.morph) { - lemmaCopy = getGrammarCopy( - category: use.category, - lemma: use.lemma, - context: context, - ) ?? - use.lemma; - } - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - width: 40, - alignment: Alignment.centerLeft, - child: Icon(use.useType.icon), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - "\"$lemmaCopy\" - ${use.useType.description(context)}", - style: const TextStyle(fontSize: 14), - ), - ), - Container( - alignment: Alignment.topRight, - width: 60, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${use.xp > 0 ? '+' : ''}${use.xp}", - style: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 14, - height: 1, - color: use.pointValueColor(context), - ), - ), - // const SizedBox(width: 5), - // const CircleAvatar( - // radius: 8, - // child: Icon( - // size: 10, - // Icons.star, - // color: Colors.white, - // ), - // ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ), + child: const LevelDialogContent(), ), ), ); diff --git a/lib/pangea/analytics_summary/level_dialog_content.dart b/lib/pangea/analytics_summary/level_dialog_content.dart new file mode 100644 index 000000000..ac1a71e5e --- /dev/null +++ b/lib/pangea/analytics_summary/level_dialog_content.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; +import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LevelDialogContent extends StatelessWidget { + const LevelDialogContent({ + super.key, + }); + + GetAnalyticsController get getAnalyticsController => + MatrixState.pangeaController.getAnalytics; + int get level => getAnalyticsController.constructListModel.level; + int get totalXP => getAnalyticsController.constructListModel.totalXP; + int get maxLevelXP => getAnalyticsController.minXPForNextLevel; + List get uses => + getAnalyticsController.constructListModel.truncatedUses; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + automaticallyImplyLeading: false, + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "⭐ ${L10n.of(context).levelShort(level)}", + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + color: AppConfig.gold, + ), + ), + Opacity( + opacity: 0.25, + child: Text( + L10n.of(context).levelShort(level + 1), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LearningProgressBar( + height: 24, + level: level, + totalXP: totalXP, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + L10n.of(context).xpIntoLevel(totalXP, maxLevelXP), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: AppConfig.gold, + ), + ), + ), + const Divider(), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: uses.length, + itemBuilder: (context, index) { + final use = uses[index]; + String lemmaCopy = use.lemma; + if (use.constructType == ConstructTypeEnum.morph) { + lemmaCopy = getGrammarCopy( + category: use.category, + lemma: use.lemma, + context: context, + ) ?? + use.lemma; + } + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + width: 40, + alignment: Alignment.centerLeft, + child: Icon(use.useType.icon), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + "\"$lemmaCopy\" - ${use.useType.description(context)}", + style: const TextStyle(fontSize: 14), + ), + ), + Container( + alignment: Alignment.topRight, + width: 60, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${use.xp > 0 ? '+' : ''}${use.xp}", + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 14, + height: 1, + color: use.pointValueColor(context), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_summary/progress_indicator.dart b/lib/pangea/analytics_summary/progress_indicator.dart index 130ad0650..fe931d19d 100644 --- a/lib/pangea/analytics_summary/progress_indicator.dart +++ b/lib/pangea/analytics_summary/progress_indicator.dart @@ -30,13 +30,9 @@ class ProgressIndicatorBadge extends StatelessWidget { ), const SizedBox(width: 6.0), !loading - ? Text( - points.toString(), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: indicator.color(context), - ), + ? _AnimatedFloatingNumber( + number: points, + indicator: indicator, ) : const SizedBox( height: 8, @@ -50,3 +46,90 @@ class ProgressIndicatorBadge extends StatelessWidget { ); } } + +class _AnimatedFloatingNumber extends StatefulWidget { + final int number; + final ProgressIndicatorEnum indicator; + + const _AnimatedFloatingNumber({ + required this.number, + required this.indicator, + }); + + @override + State<_AnimatedFloatingNumber> createState() => + _AnimatedFloatingNumberState(); +} + +class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnim; + late Animation _offsetAnim; + int? _lastNumber; + int? _floatingNumber; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _fadeAnim = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + _offsetAnim = Tween( + begin: const Offset(0, 0), + end: const Offset(0, -0.7), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _lastNumber = widget.number; + } + + @override + void didUpdateWidget(covariant _AnimatedFloatingNumber oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.number > _lastNumber!) { + _floatingNumber = widget.number; + _controller.forward(from: 0.0).then((_) { + setState(() { + _lastNumber = widget.number; + _floatingNumber = null; + }); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TextStyle indicatorStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: widget.indicator.color(context), + ); + return Stack( + alignment: Alignment.center, + children: [ + if (_floatingNumber != null) + SlideTransition( + position: _offsetAnim, + child: FadeTransition( + opacity: ReverseAnimation(_fadeAnim), + child: Text( + "$_floatingNumber", + style: indicatorStyle, + ), + ), + ), + Text( + widget.number.toString(), + style: indicatorStyle, + ), + ], + ); + } +} diff --git a/lib/pangea/analytics_summary/progress_indicators_enum.dart b/lib/pangea/analytics_summary/progress_indicators_enum.dart index 7b00aef45..15eba91e1 100644 --- a/lib/pangea/analytics_summary/progress_indicators_enum.dart +++ b/lib/pangea/analytics_summary/progress_indicators_enum.dart @@ -8,7 +8,31 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; enum ProgressIndicatorEnum { level, wordsUsed, - morphsUsed, + morphsUsed; + + static ProgressIndicatorEnum? fromString(String value) { + switch (value) { + case 'vocab': + return ProgressIndicatorEnum.wordsUsed; + case 'morphs': + return ProgressIndicatorEnum.morphsUsed; + case 'level': + return ProgressIndicatorEnum.level; + default: + return null; + } + } + + String toShortString() { + switch (this) { + case ProgressIndicatorEnum.wordsUsed: + return 'vocab'; + case ProgressIndicatorEnum.morphsUsed: + return 'morphs'; + case ProgressIndicatorEnum.level: + return 'level'; + } + } } extension ProgressIndicatorsExtension on ProgressIndicatorEnum { diff --git a/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart b/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart index 16f4649a2..f8ae1d70c 100644 --- a/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart +++ b/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart @@ -4,17 +4,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/icon_rain.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ConstructNotificationUtil { @@ -124,10 +124,15 @@ class ConstructNotificationOverlayState followerAnchor: Alignment.topCenter, targetAnchor: Alignment.topCenter, context: context, - child: PointsGainedAnimation( - points: 50, - targetID: "${widget.construct.string}_notification", - invert: true, + child: IconRain( + addStars: true, + icon: MorphIcon( + size: const Size(8, 8), + morphFeature: MorphFeaturesEnumExtension.fromString( + widget.construct.category, + ), + morphTag: widget.construct.lemma, + ), ), transformTargetId: "${widget.construct.string}_notification", closePrevOverlay: false, @@ -153,16 +158,9 @@ class ConstructNotificationOverlayState } void _showDetails() { - showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - constructZoom: widget.construct, - view: ConstructTypeEnum.morph, - backButtonOverride: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ), + context.go( + "/rooms/analytics?mode=morph", + extra: widget.construct, ); } diff --git a/lib/pangea/chat_settings/widgets/space_invite_buttons.dart b/lib/pangea/chat_settings/widgets/space_invite_buttons.dart deleted file mode 100644 index 5fd3c7347..000000000 --- a/lib/pangea/chat_settings/widgets/space_invite_buttons.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:matrix/matrix.dart'; -import 'package:universal_html/html.dart' as html; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; - -class SpaceInviteButtons extends StatefulWidget { - final Room room; - // final ScrollController scrollController; - const SpaceInviteButtons({ - super.key, - required this.room, - // required this.scrollController, - }); - - @override - SpaceInviteButtonsController createState() => SpaceInviteButtonsController(); -} - -class SpaceInviteButtonsController extends State { - // bool get isVisible { - // final context = (widget.key as GlobalKey).currentContext; - // if (context == null) return false; - - // final renderBox = context.findRenderObject() as RenderBox; - // final position = renderBox.localToGlobal(Offset.zero); - - // final size = renderBox.size; - // final screenHeight = MediaQuery.of(context).size.height; - - // debugPrint("position: $position, size: $size, screenHeight: $screenHeight"); - - // // Check if any part of the widget is within the visible range - // return position.dy + size.height > 0 && position.dy < screenHeight; - // } - - @override - void initState() { - // WidgetsBinding.instance.addPostFrameCallback( - // (_) => debugPrint("isVisible: $isVisible"), - // ); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final spaceCode = widget.room.classCode; - if (!widget.room.isSpace || spaceCode == null) { - return const SizedBox.shrink(); - } - - return SizedBox( - height: 150.0, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 16.0, - right: 16.0, - left: 16.0, - ), - child: ElevatedButton( - child: Row( - spacing: 8.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.share_outlined, - ), - Text(L10n.of(context).shareSpaceLink), - ], - ), - onPressed: () async { - final String initialUrl = - kIsWeb ? html.window.origin! : Environment.frontendURL; - final link = - "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=$spaceCode"; - await Clipboard.setData( - ClipboardData( - text: link, - ), - ); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).copiedToClipboard, - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 16.0, - right: 16.0, - left: 16.0, - ), - child: ElevatedButton( - child: Row( - spacing: 8.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.share_outlined, - ), - Text(L10n.of(context).shareInviteCode(spaceCode)), - ], - ), - onPressed: () async { - await Clipboard.setData(ClipboardData(text: spaceCode)); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).copiedToClipboard, - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/pangea/choreographer/widgets/igc/message_analytics_feedback.dart b/lib/pangea/choreographer/widgets/igc/message_analytics_feedback.dart index fb3d61bbe..569ad84ad 100644 --- a/lib/pangea/choreographer/widgets/igc/message_analytics_feedback.dart +++ b/lib/pangea/choreographer/widgets/igc/message_analytics_feedback.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -99,12 +100,15 @@ class MessageAnalyticsFeedbackState extends State } void _showAnalyticsDialog(ConstructTypeEnum? type) { - showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - view: type ?? ConstructTypeEnum.vocab, - ), - ); + switch (type) { + case ConstructTypeEnum.morph: + context.go("/rooms/analytics?mode=morph"); + break; + case ConstructTypeEnum.vocab: + default: + context.go("/rooms/analytics?mode=vocab"); + break; + } } @override diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index 5bb4f2c86..433ef026c 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -65,6 +65,8 @@ class PApiUrls { "${PApiUrls.choreoEndpoint}/practice"; static String lemmaDictionary = "${PApiUrls.choreoEndpoint}/lemma_definition"; + static String lemmaDictionaryEdit = + "${PApiUrls.choreoEndpoint}/lemma_definition/edit"; static String morphDictionary = "${PApiUrls.choreoEndpoint}/morph_meaning"; static String activityPlanGeneration = diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index e2d7f76e0..70ed108e6 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -21,7 +21,7 @@ class OverlayUtil { static showOverlay({ required BuildContext context, required Widget child, - required String transformTargetId, + String? transformTargetId, backDropToDismiss = true, blurBackground = false, Color? borderColor, @@ -37,6 +37,13 @@ class OverlayUtil { bool canPop = true, }) { try { + if (position == OverlayPositionEnum.transform) { + assert( + transformTargetId != null, + "transformTargetId must be provided when position is OverlayPositionEnum.transform", + ); + } + if (closePrevOverlay) { MatrixState.pAnyState.closeOverlay(); } @@ -77,7 +84,7 @@ class OverlayUtil { followerAnchor: followerAnchor ?? Alignment.bottomCenter, link: MatrixState.pAnyState - .layerLinkAndKey(transformTargetId) + .layerLinkAndKey(transformTargetId!) .link, showWhenUnlinked: false, offset: offset ?? Offset.zero, diff --git a/lib/pangea/find_your_people/find_your_people_side_view.dart b/lib/pangea/common/widgets/pangea_side_view.dart similarity index 70% rename from lib/pangea/find_your_people/find_your_people_side_view.dart rename to lib/pangea/common/widgets/pangea_side_view.dart index 0514455e7..46c2f0feb 100644 --- a/lib/pangea/find_your_people/find_your_people_side_view.dart +++ b/lib/pangea/common/widgets/pangea_side_view.dart @@ -5,11 +5,27 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; import 'package:fluffychat/pangea/find_your_people/find_your_people_constants.dart'; import 'package:fluffychat/widgets/navigation_rail.dart'; -class FindYourPeopleSideView extends StatelessWidget { - const FindYourPeopleSideView({super.key}); +class PangeaSideView extends StatelessWidget { + final String? path; + const PangeaSideView({ + super.key, + required this.path, + }); + + String get _asset { + const defaultAsset = FindYourPeopleConstants.sideBearFileName; + if (path == null || path!.isEmpty) return defaultAsset; + + if (path!.contains('analytics')) { + return AnalyticsPageConstants.dinoBotFileName; + } + + return defaultAsset; + } @override Widget build(BuildContext context) { @@ -32,8 +48,7 @@ class FindYourPeopleSideView extends StatelessWidget { child: SizedBox( width: 250.0, child: CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${FindYourPeopleConstants.sideBearFileName}", + imageUrl: "${AppConfig.assetsBaseURL}/$_asset", errorWidget: (context, url, error) => const SizedBox(), placeholder: (context, url) => const Center( child: CircularProgressIndicator.adaptive(), diff --git a/lib/pangea/constructs/construct_repo.dart b/lib/pangea/constructs/construct_repo.dart index 6d97ce0af..d847f8168 100644 --- a/lib/pangea/constructs/construct_repo.dart +++ b/lib/pangea/constructs/construct_repo.dart @@ -11,6 +11,8 @@ import 'package:fluffychat/widgets/matrix.dart'; class ConstructSummary { final int upperLevel; final int lowerLevel; + int? levelVocabConstructs; + int? levelGrammarConstructs; final String language; final String textSummary; final int writingConstructScore; @@ -21,6 +23,8 @@ class ConstructSummary { ConstructSummary({ required this.upperLevel, required this.lowerLevel, + this.levelVocabConstructs, + this.levelGrammarConstructs, required this.language, required this.textSummary, required this.writingConstructScore, @@ -33,6 +37,8 @@ class ConstructSummary { return { 'upper_level': upperLevel, 'lower_level': lowerLevel, + 'level_grammar_constructs': levelGrammarConstructs, + 'level_vocab_constructs': levelVocabConstructs, 'language': language, 'text_summary': textSummary, 'writing_construct_score': writingConstructScore, @@ -46,6 +52,8 @@ class ConstructSummary { return ConstructSummary( upperLevel: json['upper_level'], lowerLevel: json['lower_level'], + levelGrammarConstructs: json['level_grammar_constructs'], + levelVocabConstructs: json['level_vocab_constructs'], language: json['language'], textSummary: json['text_summary'], writingConstructScore: json['writing_construct_score'], diff --git a/lib/pangea/events/event_wrappers/pangea_message_event.dart b/lib/pangea/events/event_wrappers/pangea_message_event.dart index d0fff2281..9df0b2e0d 100644 --- a/lib/pangea/events/event_wrappers/pangea_message_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_message_event.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:developer'; +import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -18,6 +19,7 @@ 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/stt_translation_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/spaces/models/space_model.dart'; @@ -266,6 +268,21 @@ class PangeaMessageEvent { final botTranscription = SpeechToTextModel.fromJson( Map.from(rawBotTranscription), ); + + _representations?.add( + RepresentationEvent( + timeline: timeline, + parentMessageEvent: _event, + content: PangeaRepresentation( + langCode: botTranscription.langCode, + text: botTranscription.transcript.text, + originalSent: false, + originalWritten: false, + speechToText: botTranscription, + ), + ), + ); + return botTranscription; } @@ -776,4 +793,9 @@ class PangeaMessageEvent { tag: tag, ); } + + TextDirection get textDirection => + PLanguageStore.rtlLanguageCodes.contains(messageDisplayLangCode) + ? TextDirection.rtl + : TextDirection.ltr; } diff --git a/lib/pangea/learning_settings/models/language_model.dart b/lib/pangea/learning_settings/models/language_model.dart index 4029b9507..285577bfc 100644 --- a/lib/pangea/learning_settings/models/language_model.dart +++ b/lib/pangea/learning_settings/models/language_model.dart @@ -1,21 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/l10n/l10n.dart'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/enums/l2_support_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; class LanguageModel { final String langCode; final String displayName; final String script; final L2SupportEnum l2Support; + final TextDirection? _textDirection; LanguageModel({ required this.langCode, required this.displayName, this.script = LanguageKeys.unknownLanguage, this.l2Support = L2SupportEnum.na, - }); + TextDirection? textDirection, + }) : _textDirection = textDirection; factory LanguageModel.fromJson(json) { final String code = json['language_code'] ?? @@ -31,6 +35,11 @@ class LanguageModel { ? L2SupportEnum.na.fromStorageString(json['l2_support']) : L2SupportEnum.na, script: json['script'] ?? LanguageKeys.unknownLanguage, + textDirection: json['text_direction'] != null + ? TextDirection.values.firstWhereOrNull( + (e) => e.name == json['text_direction'], + ) + : null, ); } @@ -39,6 +48,7 @@ class LanguageModel { 'language_name': displayName, 'script': script, 'l2_support': l2Support.storageString, + 'text_direction': textDirection.name, }; bool get l2 => l2Support != L2SupportEnum.na; @@ -60,19 +70,22 @@ class LanguageModel { displayName: "Unknown", ); - static LanguageModel multiLingual([BuildContext? context]) => LanguageModel( - displayName: context != null - ? L10n.of(context).multiLingualSpace - : "Multilingual Space", - langCode: LanguageKeys.multiLanguage, - ); - String? getDisplayName(BuildContext context) { return displayName; } String get langCodeShort => langCode.split('-').first; + TextDirection get _defaultTextDirection { + return PLanguageStore.rtlLanguageCodes.contains(langCodeShort) + ? TextDirection.rtl + : TextDirection.ltr; + } + + TextDirection get textDirection { + return _textDirection ?? _defaultTextDirection; + } + @override bool operator ==(Object other) { if (other is LanguageModel) { diff --git a/lib/pangea/learning_settings/utils/p_language_store.dart b/lib/pangea/learning_settings/utils/p_language_store.dart index ed66d2bf9..60310f18b 100644 --- a/lib/pangea/learning_settings/utils/p_language_store.dart +++ b/lib/pangea/learning_settings/utils/p_language_store.dart @@ -42,7 +42,6 @@ class PLanguageStore { _langList = _langList.toSet().toList(); _langList.sort((a, b) => a.displayName.compareTo(b.displayName)); - _langList.insert(0, LanguageModel.multiLingual()); } catch (err, stack) { debugger(when: kDebugMode); ErrorHandler.logError( @@ -107,4 +106,19 @@ class PLanguageStore { } return null; } + + static final List rtlLanguageCodes = [ + 'ar', + 'arc', + 'dv', + 'fa', + 'ha', + 'he', + 'khw', + 'ks', + 'ku', + 'ps', + 'ur', + 'yi', + ]; } diff --git a/lib/pangea/learning_settings/widgets/p_language_dropdown.dart b/lib/pangea/learning_settings/widgets/p_language_dropdown.dart index c71fea714..d47b48cf0 100644 --- a/lib/pangea/learning_settings/widgets/p_language_dropdown.dart +++ b/lib/pangea/learning_settings/widgets/p_language_dropdown.dart @@ -14,7 +14,6 @@ class PLanguageDropdown extends StatefulWidget { final List languages; final LanguageModel? initialLanguage; final Function(LanguageModel) onChange; - final bool showMultilingual; final bool isL2List; final String? decorationText; final String? error; @@ -28,7 +27,6 @@ class PLanguageDropdown extends StatefulWidget { required this.languages, required this.onChange, required this.initialLanguage, - this.showMultilingual = false, this.decorationText, this.isL2List = false, this.error, @@ -132,15 +130,6 @@ class PLanguageDropdownState extends State { ), ), items: [ - if (widget.showMultilingual) - DropdownMenuItem( - value: LanguageModel.multiLingual(context), - enabled: widget.enabled, - child: LanguageDropDownEntry( - languageModel: LanguageModel.multiLingual(context), - isL2List: widget.isL2List, - ), - ), ...sortedLanguages.map( (languageModel) => DropdownMenuItem( value: languageModel, diff --git a/lib/pangea/lemmas/lemma_edit_request.dart b/lib/pangea/lemmas/lemma_edit_request.dart new file mode 100644 index 000000000..568e5356e --- /dev/null +++ b/lib/pangea/lemmas/lemma_edit_request.dart @@ -0,0 +1,40 @@ +class LemmaEditRequest { + String lemma; + String partOfSpeech; + String lemmaLang; + String userL1; + + String? newMeaning; + List? newEmojis; + + LemmaEditRequest({ + required this.lemma, + required this.partOfSpeech, + required this.lemmaLang, + required this.userL1, + this.newMeaning, + this.newEmojis, + }); + + Map toJson() { + return { + "lemma": lemma, + "part_of_speech": partOfSpeech, + "lemma_lang": lemmaLang, + "user_l1": userL1, + "new_meaning": newMeaning, + "new_emojis": newEmojis, + }; + } + + factory LemmaEditRequest.fromJson(Map json) { + return LemmaEditRequest( + lemma: json["lemma"], + partOfSpeech: json["part_of_speech"], + lemmaLang: json["lemma_lang"], + userL1: json["user_l1"], + newMeaning: json["new_meaning"], + newEmojis: List.from(json["new_emojis"] ?? []), + ); + } +} diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 5624a6f38..bd3bb785e 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_edit_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -101,4 +102,35 @@ class LemmaInfoRepo { rethrow; } } + + static Future edit(LemmaEditRequest request) async { + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final resp = await req.post( + url: PApiUrls.lemmaDictionaryEdit, + body: request.toJson(), + ); + + if (resp.statusCode != 200) { + throw Exception( + 'Failed to edit lemma: ${resp.statusCode} ${resp.body}', + ); + } + + final decodedBody = jsonDecode(utf8.decode(resp.bodyBytes)); + final response = LemmaInfoResponse.fromJson(decodedBody); + + set( + LemmaInfoRequest( + lemma: request.lemma, + partOfSpeech: request.partOfSpeech, + lemmaLang: request.lemmaLang, + userL1: request.userL1, + ), + response, + ); + } } diff --git a/lib/pangea/message_token_text/message_token_button.dart b/lib/pangea/message_token_text/message_token_button.dart index 2b2624948..e4ac8cb1a 100644 --- a/lib/pangea/message_token_text/message_token_button.dart +++ b/lib/pangea/message_token_text/message_token_button.dart @@ -31,7 +31,6 @@ class MessageTokenButton extends StatefulWidget { final TextStyle textStyle; final double width; final bool animateIn; - final PracticeTarget? practiceTargetForToken; const MessageTokenButton({ super.key, @@ -39,7 +38,6 @@ class MessageTokenButton extends StatefulWidget { required this.token, required this.textStyle, required this.width, - required this.practiceTargetForToken, this.animateIn = false, }); @@ -124,9 +122,10 @@ class MessageTokenButtonState extends State super.dispose(); } - bool get _animate => widget.animateIn || _finishedInitialAnimation; + PracticeTarget? get _activity => + widget.overlayController?.practiceTargetForToken(widget.token); - PracticeTarget? get _activity => widget.practiceTargetForToken; + bool get _animate => widget.animateIn || _finishedInitialAnimation; bool get _isActivityCompleteOrNullForToken => _activity?.isCompleteByToken( diff --git a/lib/pangea/public_spaces/public_space_card.dart b/lib/pangea/public_spaces/public_space_card.dart deleted file mode 100644 index 29521f67b..000000000 --- a/lib/pangea/public_spaces/public_space_card.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; -import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart'; -import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; - -class PublicSpaceCard extends StatelessWidget { - final PublicRoomsChunk space; - final double width; - final double height; - - const PublicSpaceCard({ - super.key, - required this.space, - required this.width, - required this.height, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return PressableButton( - onPressed: () => PublicRoomBottomSheet.show( - roomAlias: space.canonicalAlias ?? space.roomId, - chunk: space, - context: context, - ), - borderRadius: BorderRadius.circular(24.0), - color: theme.brightness == Brightness.dark - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHighest, - colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - height: height, - width: width, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(24.0), - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: height, - width: height, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24.0), - child: space.avatarUrl != null - ? MxcImage( - uri: space.avatarUrl!, - width: width, - height: width, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: space.defaultAvatar(), - width: width, - height: width, - fit: BoxFit.cover, - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - spacing: 4.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - space.name ?? '', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(24.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 8.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - const Icon( - Icons.group_outlined, - size: 12.0, - ), - Text( - L10n.of(context).countParticipants( - space.numJoinedMembers, - ), - style: theme.textTheme.labelSmall, - ), - ], - ), - ), - ], - ), - Flexible( - child: Text( - space.topic ?? - L10n.of(context).noSpaceDescriptionYet, - style: theme.textTheme.bodySmall, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, - maxLines: 5, - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/pangea/public_spaces/public_spaces_area.dart b/lib/pangea/public_spaces/public_spaces_area.dart deleted file mode 100644 index 0e8fadf6b..000000000 --- a/lib/pangea/public_spaces/public_spaces_area.dart +++ /dev/null @@ -1,215 +0,0 @@ -// shows n rows of activity suggestions vertically, where n is the number of rows -// as the user tries to scroll horizontally to the right, the client will fetch more activity suggestions - -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; -import 'package:shimmer/shimmer.dart'; - -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/public_spaces/public_space_card.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class PublicSpacesArea extends StatefulWidget { - const PublicSpacesArea({super.key}); - - @override - PublicSpacesAreaState createState() => PublicSpacesAreaState(); -} - -class PublicSpacesAreaState extends State { - @override - void initState() { - super.initState(); - _setSpaceItems(); - } - - @override - void dispose() { - _scrollController.dispose(); - _searchController.dispose(); - _coolDown?.cancel(); - super.dispose(); - } - - bool _loading = true; - bool _isSearching = false; - - final List _spaceItems = []; - - final ScrollController _scrollController = ScrollController(); - final TextEditingController _searchController = TextEditingController(); - Timer? _coolDown; - - final double cardHeight = 150.0; - final double cardWidth = 325.0; - - Future _setSpaceItems() async { - _spaceItems.clear(); - setState(() => _loading = true); - try { - final resp = await Matrix.of(context).client.queryPublicRooms( - filter: PublicRoomQueryFilter( - roomTypes: ['m.space'], - genericSearchTerm: _searchController.text, - ), - limit: 100, - ); - _spaceItems.addAll(resp.chunk); - _spaceItems.sort((a, b) { - int getPriority(item) { - final bool hasTopic = item.topic != null && item.topic!.isNotEmpty; - final bool hasAvatar = item.avatarUrl != null; - - if (hasTopic && hasAvatar) return 0; // Highest priority - if (hasAvatar) return 1; // Second priority - if (hasTopic) return 2; // Third priority - return 3; // Lowest priority - } - - return getPriority(a).compareTo(getPriority(b)); - }); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - void _onSearchEnter(String text, {bool globalSearch = true}) { - if (text.isEmpty) { - _setSpaceItems(); - return; - } - - _coolDown?.cancel(); - _coolDown = Timer(const Duration(milliseconds: 500), _setSpaceItems); - } - - void _toggleSearching() { - setState(() { - _isSearching = !_isSearching; - _searchController.clear(); - _setSpaceItems(); - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isColumnMode = FluffyThemes.isColumnMode(context); - - final List cards = _loading && _spaceItems.isEmpty - ? List.generate(5, (i) { - return Shimmer.fromColors( - baseColor: theme.colorScheme.primary.withAlpha(20), - highlightColor: theme.colorScheme.primary.withAlpha(50), - child: Container( - height: cardHeight, - width: cardWidth, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(24.0), - ), - ), - ); - }) - : _spaceItems - .map((space) { - return PublicSpaceCard( - space: space, - width: cardWidth, - height: cardHeight, - ); - }) - .cast() - .toList(); - - if (_loading && _spaceItems.isNotEmpty) { - cards.add( - const Padding( - padding: EdgeInsets.all(16.0), - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - return Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: child, - ), - child: _isSearching - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - key: const ValueKey('search'), - children: [ - Expanded( - child: TextField( - autofocus: true, - controller: _searchController, - onChanged: _onSearchEnter, - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 12.0, - ), - isDense: true, - border: OutlineInputBorder(), - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: _toggleSearching, - ), - ], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - key: const ValueKey('title'), - children: [ - Text( - L10n.of(context).findYourPeople, - style: isColumnMode - ? theme.textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold) - : theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - IconButton( - icon: const Icon(Icons.search), - onPressed: _toggleSearching, - ), - ], - ), - ), - Container( - decoration: const BoxDecoration(), - child: Scrollbar( - thumbVisibility: true, - controller: _scrollController, - child: Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: SingleChildScrollView( - controller: _scrollController, - scrollDirection: Axis.horizontal, - child: Row( - spacing: 8.0, - children: cards, - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/pangea/toolbar/utils/token_rendering_util.dart b/lib/pangea/toolbar/utils/token_rendering_util.dart index 8efbeeedc..2a61659a5 100644 --- a/lib/pangea/toolbar/utils/token_rendering_util.dart +++ b/lib/pangea/toolbar/utils/token_rendering_util.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; @@ -85,7 +86,10 @@ class TokenRenderingUtil { } } - Color backgroundColor(BuildContext context, bool selected) { + Color backgroundColor(BuildContext context, bool selected, bool isNew) { + if (isNew) { + return AppConfig.success; + } return selected ? Theme.of(context).colorScheme.primary : Colors.white.withAlpha(0); diff --git a/lib/pangea/toolbar/widgets/icon_rain.dart b/lib/pangea/toolbar/widgets/icon_rain.dart new file mode 100644 index 000000000..7b9d41ada --- /dev/null +++ b/lib/pangea/toolbar/widgets/icon_rain.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class IconRain extends StatefulWidget { + final Widget icon; + final int burstCount; + final int taperCount; + final Duration burstDuration; + final Duration taperDuration; + final Duration fallDuration; + final double swayAmplitude; // in pixels + final double swayFrequency; // in Hz + final bool addStars; + + const IconRain({ + super.key, + required this.icon, + this.burstCount = 20, + this.taperCount = 8, + this.burstDuration = const Duration(milliseconds: 300), + this.taperDuration = const Duration(milliseconds: 900), + this.fallDuration = const Duration(seconds: 2), + this.swayAmplitude = 32.0, + this.swayFrequency = 0.8, + this.addStars = false, + }); + + @override + State createState() => _IconRainState(); +} + +class _IconRainState extends State with TickerProviderStateMixin { + final List<_FallingIcon> _icons = []; + final Random _random = Random(); + Timer? _burstTimer; + Timer? _taperTimer; + int _burstSpawned = 0; + int _taperSpawned = 0; + + @override + void initState() { + super.initState(); + _startBurst(); + } + + Widget _getIcon() { + if (widget.addStars && _random.nextBool()) { + return const Text('⭐', style: TextStyle(fontSize: 12)); + } + return widget.icon; + } + + void _startBurst() { + final burstInterval = widget.burstDuration ~/ widget.burstCount; + _burstTimer = Timer.periodic(burstInterval, (timer) { + if (!mounted) return; + setState(() { + _icons.add( + _FallingIcon( + key: UniqueKey(), + icon: _getIcon(), + startX: _random.nextDouble(), + duration: widget.fallDuration, + swayAmplitude: widget.swayAmplitude, + swayFrequency: widget.swayFrequency, + fadeMidpoint: 0.4 + _random.nextDouble() * 0.2, // 40-60% down + onComplete: () { + setState(() { + _icons.removeWhere((i) => i.key == _icons.first.key); + }); + }, + ), + ); + _burstSpawned++; + if (_burstSpawned >= widget.burstCount) { + _burstTimer?.cancel(); + _startTaper(); + } + }); + }); + } + + void _startTaper() { + if (widget.taperCount == 0) return; + final taperInterval = widget.taperDuration ~/ widget.taperCount; + _taperTimer = Timer.periodic(taperInterval, (timer) { + if (!mounted) return; + setState(() { + _icons.add( + _FallingIcon( + key: UniqueKey(), + icon: _getIcon(), + startX: _random.nextDouble(), + duration: widget.fallDuration, + swayAmplitude: widget.swayAmplitude, + swayFrequency: widget.swayFrequency, + fadeMidpoint: 0.4 + _random.nextDouble() * 0.2, // 40-60% down + onComplete: () { + setState(() { + _icons.removeWhere((i) => i.key == _icons.first.key); + }); + }, + ), + ); + _taperSpawned++; + if (_taperSpawned >= widget.taperCount) { + _taperTimer?.cancel(); + } + }); + }); + } + + @override + void dispose() { + _burstTimer?.cancel(); + _taperTimer?.cancel(); + _icons.clear(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: _icons.map((icon) { + return icon.build( + context, + constraints.maxWidth, + constraints.maxHeight, + ); + }).toList(), + ); + }, + ); + } +} + +class _FallingIcon { + final Key key; + final Widget icon; + final double startX; + final Duration duration; + final double swayAmplitude; + final double swayFrequency; + final double fadeMidpoint; + final VoidCallback onComplete; + + _FallingIcon({ + required this.key, + required this.icon, + required this.startX, + required this.duration, + required this.swayAmplitude, + required this.swayFrequency, + required this.fadeMidpoint, + required this.onComplete, + }); + + Widget build(BuildContext context, double maxWidth, double maxHeight) { + return _AnimatedFallingIcon( + key: key, + icon: icon, + startX: startX, + duration: duration, + maxWidth: maxWidth, + maxHeight: maxHeight, + swayAmplitude: swayAmplitude, + swayFrequency: swayFrequency, + fadeMidpoint: fadeMidpoint, + onComplete: onComplete, + ); + } +} + +class _AnimatedFallingIcon extends StatefulWidget { + final Widget icon; + final double startX; + final Duration duration; + final double maxWidth; + final double maxHeight; + final double swayAmplitude; + final double swayFrequency; + final double fadeMidpoint; + final VoidCallback onComplete; + + const _AnimatedFallingIcon({ + super.key, + required this.icon, + required this.startX, + required this.duration, + required this.maxWidth, + required this.maxHeight, + required this.swayAmplitude, + required this.swayFrequency, + required this.fadeMidpoint, + required this.onComplete, + }); + + @override + State<_AnimatedFallingIcon> createState() => _AnimatedFallingIconState(); +} + +class _AnimatedFallingIconState extends State<_AnimatedFallingIcon> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + _animation = Tween(begin: -40, end: widget.maxHeight + 40).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeIn), + ); + _controller.forward().then((_) => widget.onComplete()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final progress = _controller.value; + final sway = widget.swayAmplitude * + sin( + 2 * pi * widget.swayFrequency * progress + widget.startX * 2 * pi, + ); + // Fade out after fadeMidpoint + double opacity = 1.0; + if (progress > widget.fadeMidpoint) { + final fadeProgress = + (progress - widget.fadeMidpoint) / (1 - widget.fadeMidpoint); + opacity = 1.0 - fadeProgress.clamp(0.0, 1.0); + } + return Positioned( + left: widget.startX * (widget.maxWidth - 40) + sway, + top: _animation.value, + child: Opacity( + opacity: opacity, + child: SizedBox(width: 40, height: 40, child: widget.icon), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/toolbar/widgets/message_audio_card.dart b/lib/pangea/toolbar/widgets/message_audio_card.dart index 2cc529422..3b854b5bf 100644 --- a/lib/pangea/toolbar/widgets/message_audio_card.dart +++ b/lib/pangea/toolbar/widgets/message_audio_card.dart @@ -8,7 +8,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; @@ -96,9 +95,7 @@ class MessageAudioCardState extends State { ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onPrimary, ) - : const CardErrorWidget( - error: "Null audio file in message_audio_card", - ); + : const SizedBox(); } } diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index d95fee56a..440558bd8 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -11,19 +11,22 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; @@ -69,8 +72,6 @@ class MessageOverlayController extends State ///////////////////////////////////// MessageMode toolbarMode = MessageMode.noneSelected; - Map? messageLemmaInfos; - /// set and cleared by the PracticeActivityCard /// has to be at this level so drag targets can access it PracticeActivityModel? activity; @@ -103,6 +104,8 @@ class MessageOverlayController extends State double maxWidth = AppConfig.toolbarMinWidth; + List newTokens = []; + ///////////////////////////////////// /// Lifecycle ///////////////////////////////////// @@ -148,27 +151,13 @@ class MessageOverlayController extends State ); } - // Get all the lemma infos - final messageVocabConstructIds = pangeaMessageEvent! - .messageDisplayRepresentation!.tokensToSave - .map((e) => e.vocabConstructID) - .toList(); - - final List> lemmaInfoFutures = - messageVocabConstructIds - .map((token) => token.getLemmaInfo()) - .toList(); - - Future.wait(lemmaInfoFutures).then((resp) { - if (mounted) { - setState( - () => messageLemmaInfos = Map.fromIterables( - messageVocabConstructIds, - resp, - ), - ); - } - }); + newTokens = pangeaMessageEvent?.messageDisplayRepresentation?.tokens + ?.where((token) { + return token.lemma.saveVocab == true && + token.vocabConstruct.uses.isEmpty && + messageInUserL2; + }).toList() ?? + []; } catch (e, s) { debugger(when: kDebugMode); ErrorHandler.logError( @@ -181,7 +170,6 @@ class MessageOverlayController extends State } finally { _initializeSelectedToken(); _setInitialToolbarMode(); - messageLemmaInfos ??= {}; initialized = true; if (mounted) setState(() {}); } @@ -216,16 +204,16 @@ class MessageOverlayController extends State updateSelectedSpan(widget._initialSelectedToken!.text); - int retries = 0; - while (retries < 5 && - selectedToken != null && - !MatrixState.pAnyState.isOverlayOpen( - selectedToken!.text.uniqueKey, - )) { - await Future.delayed(const Duration(milliseconds: 100)); - _showReadingAssistanceContent(); - retries++; - } + // int retries = 0; + // while (retries < 5 && + // selectedToken != null && + // !MatrixState.pAnyState.isOverlayOpen( + // selectedToken!.text.uniqueKey, + // )) { + // await Future.delayed(const Duration(milliseconds: 100)); + // _showReadingAssistanceContent(); + // retries++; + // } } ///////////////////////////////////// @@ -295,9 +283,9 @@ class MessageOverlayController extends State } if (mounted) setState(() {}); - Future.delayed(const Duration(milliseconds: 10), () { - _showReadingAssistanceContent(); - }); + if (selectedToken != null && isNewToken(selectedToken!)) { + _onSelectNewToken(selectedToken!); + } } void _showReadingAssistanceContent() { @@ -427,7 +415,6 @@ class MessageOverlayController extends State bool get isTranslationUnlocked => pangeaMessageEvent?.ownMessage == true || !messageInUserL2 || - (messageLemmaInfos?.isEmpty ?? false) || isEmojiDone || isMeaningDone || isListeningDone || @@ -558,6 +545,52 @@ class MessageOverlayController extends State updateSelectedSpan(token.text); } + void _onSelectNewToken(PangeaToken token) { + if (!isNewToken(token)) return; + Future.delayed(const Duration(milliseconds: 1700), () { + MatrixState.pangeaController.putAnalytics.setState( + AnalyticsStream( + eventId: event.eventId, + roomId: event.room.id, + constructs: [ + OneConstructUse( + useType: ConstructUseTypeEnum.click, + lemma: token.lemma.text, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: event.room.id, + timeStamp: DateTime.now(), + eventId: event.eventId, + ), + category: token.pos, + form: token.text.content, + xp: ConstructUseTypeEnum.click.pointValue, + ), + ], + targetID: token.text.uniqueKey, + ), + ); + + if (mounted) { + setState(() { + newTokens.removeWhere( + (t) => + t.text.offset == token.text.offset && + t.text.length == token.text.length && + t.lemma.text.equals(token.lemma.text), + ); + }); + } + }); + } + + PracticeTarget? practiceTargetForToken(PangeaToken token) { + if (toolbarMode.associatedActivityType == null) return null; + return practiceSelection + ?.activities(toolbarMode.associatedActivityType!) + .firstWhereOrNull((a) => a.tokens.contains(token)); + } + /// Whether the given token is currently selected or highlighted bool isTokenSelected(PangeaToken token) { final isSelected = _selectedSpan?.offset == token.text.offset && @@ -565,6 +598,15 @@ class MessageOverlayController extends State return isSelected; } + bool isNewToken(PangeaToken token) { + if (newTokens.isEmpty) return false; + return newTokens.any( + (t) => + t.text.offset == token.text.offset && + t.text.length == token.text.length, + ); + } + bool isTokenHighlighted(PangeaToken token) { if (_highlightedTokens == null) return false; return _highlightedTokens!.any( diff --git a/lib/pangea/toolbar/widgets/message_selection_positioner.dart b/lib/pangea/toolbar/widgets/message_selection_positioner.dart index bbfad506d..375bf8000 100644 --- a/lib/pangea/toolbar/widgets/message_selection_positioner.dart +++ b/lib/pangea/toolbar/widgets/message_selection_positioner.dart @@ -3,25 +3,22 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; -import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; -import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/overlay_footer.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/measure_render_box.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/overlay_center_content.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/overlay_header.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/reading_assistance_content.dart'; import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; +import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -54,39 +51,57 @@ class MessageSelectionPositioner extends StatefulWidget { class MessageSelectionPositionerState extends State with TickerProviderStateMixin { - late AnimationController _animationController; + // late AnimationController _animationController; - Offset? _centeredMessageOffset; - Size? _centeredMessageSize; + // Offset? _centeredMessageOffset; + // Size? _centeredMessageSize; - Size? _tooltipSize; + // Size? _tooltipSize; - final Completer _centeredMessageCompleter = Completer(); - final Completer _tooltipCompleter = Completer(); + // final Completer _centeredMessageCompleter = Completer(); + // final Completer _tooltipCompleter = Completer(); - MessageMode _currentMode = MessageMode.noneSelected; + // MessageMode _currentMode = MessageMode.noneSelected; - Animation? _overlayOffsetAnimation; - Animation? _messageSizeAnimation; - Offset? _currentOffset; + // Animation? _overlayOffsetAnimation; + // Animation? _messageSizeAnimation; + // Offset? _currentOffset; StreamSubscription? _reactionSubscription; StreamSubscription? _contentChangedSubscription; - final _animationDuration = const Duration( - milliseconds: AppConfig.overlayAnimationDuration, - // seconds: 5, - ); + ScrollController? _scrollController; + + // final _animationDuration = const Duration( + // milliseconds: AppConfig.overlayAnimationDuration, + // // seconds: 5, + // ); @override void initState() { super.initState(); - _currentMode = widget.overlayController.toolbarMode; - _animationController = AnimationController( - vsync: this, - duration: _animationDuration, + _scrollController = ScrollController(); + Future.delayed( + const Duration(milliseconds: 100), + () { + if (_scrollController == null || !_scrollController!.hasClients) { + return; + } + + // _scrollController!.animateTo( + // _scrollController!.position.maxScrollExtent, + // duration: FluffyThemes.animationDuration, + // curve: FluffyThemes.animationCurve, + // ); + }, ); + // // _currentMode = widget.overlayController.toolbarMode; + // _animationController = AnimationController( + // vsync: this, + // duration: _animationDuration, + // ); + _reactionSubscription = widget.chatController.room.client.onSync.stream.where( (update) { @@ -111,153 +126,358 @@ class MessageSelectionPositionerState extends State .overlayController.contentChangedStream.stream .listen(_onContentSizeChanged); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await _centeredMessageCompleter.future; - if (!mounted) return; + // WidgetsBinding.instance.addPostFrameCallback((_) async { + // await _centeredMessageCompleter.future; + // if (!mounted) return; - setState(() { - _currentOffset = Offset( - _ownMessage ? _messageRightOffset : _messageLeftOffset, - _originalMessageBottomOffset - - _reactionsHeight - - _selectionButtonsHeight, - ); - }); + // setState(() { + // _currentOffset = Offset( + // _ownMessage ? _messageRightOffset : _messageLeftOffset, + // _originalMessageBottomOffset - + // _reactionsHeight - + // _selectionButtonsHeight, + // ); + // }); - _setReadingAssistanceMode( - ReadingAssistanceMode.selectMode, - ); - }); + // _setReadingAssistanceMode( + // ReadingAssistanceMode.selectMode, + // ); + // }); } - @override - void didUpdateWidget(MessageSelectionPositioner oldWidget) { - super.didUpdateWidget(oldWidget); - final mode = widget.overlayController.toolbarMode; - if (mode != _currentMode) { - setState(() => _currentMode = mode); - } - } + // @override + // void didUpdateWidget(MessageSelectionPositioner oldWidget) { + // super.didUpdateWidget(oldWidget); + // final mode = widget.overlayController.toolbarMode; + // if (mode != _currentMode) { + // setState(() => _currentMode = mode); + // } + // } @override void dispose() { - _animationController.dispose(); + // _animationController.dispose(); _reactionSubscription?.cancel(); _contentChangedSubscription?.cancel(); + _scrollController?.dispose(); MatrixState.pangeaController.matrixState.audioPlayer ?..stop() ..dispose(); super.dispose(); } - void _setCenteredMessageSize(RenderBox renderBox) { - if (_centeredMessageCompleter.isCompleted) return; + // void _setCenteredMessageSize(RenderBox renderBox) { + // if (_centeredMessageCompleter.isCompleted) return; - _centeredMessageSize = renderBox.size; - final offset = renderBox.localToGlobal(Offset.zero); - _centeredMessageOffset = Offset( - offset.dx - _columnWidth - _horizontalPadding - 2.0, - _mediaQuery!.size.height - - (offset.dy - - ((AppConfig.practiceModeInputBarHeight - - AppConfig.selectModeInputBarHeight) * - 0.75)) - - renderBox.size.height - - _reactionsHeight, - ); - setState(() {}); + // _centeredMessageSize = renderBox.size; + // final offset = renderBox.localToGlobal(Offset.zero); + // _centeredMessageOffset = Offset( + // offset.dx - _columnWidth - _horizontalPadding - 2.0, + // _mediaQuery!.size.height - + // (offset.dy - + // ((AppConfig.practiceModeInputBarHeight - + // AppConfig.selectModeInputBarHeight) * + // 0.75)) - + // renderBox.size.height - + // _reactionsHeight, + // ); + // setState(() {}); - if (!_centeredMessageCompleter.isCompleted) { - _centeredMessageCompleter.complete(); - } - } + // if (!_centeredMessageCompleter.isCompleted) { + // _centeredMessageCompleter.complete(); + // } + // } - void _setTooltipSize(RenderBox renderBox) { - setState(() { - _tooltipSize = renderBox.size; - }); + // void _setTooltipSize(RenderBox renderBox) { + // setState(() { + // _tooltipSize = renderBox.size; + // }); - if (!_tooltipCompleter.isCompleted) { - _tooltipCompleter.complete(); - } - } + // if (!_tooltipCompleter.isCompleted) { + // _tooltipCompleter.complete(); + // } + // } - Future _setReadingAssistanceMode(ReadingAssistanceMode mode) async { - if (mode == _readingAssistanceMode) { - return; - } + // Future _setReadingAssistanceMode(ReadingAssistanceMode mode) async { + // if (mode == _readingAssistanceMode) { + // return; + // } - await _centeredMessageCompleter.future; + // await _centeredMessageCompleter.future; - if (mode == ReadingAssistanceMode.practiceMode) { - setState( - () => widget.overlayController.readingAssistanceMode = - ReadingAssistanceMode.transitionMode, - ); - } else if (mode == ReadingAssistanceMode.selectMode) { - setState( - () => widget.overlayController.readingAssistanceMode = - ReadingAssistanceMode.selectMode, - ); - } + // if (mode == ReadingAssistanceMode.practiceMode) { + // setState( + // () => widget.overlayController.readingAssistanceMode = + // ReadingAssistanceMode.transitionMode, + // ); + // } else if (mode == ReadingAssistanceMode.selectMode) { + // setState( + // () => widget.overlayController.readingAssistanceMode = + // ReadingAssistanceMode.selectMode, + // ); + // } - if (mode == ReadingAssistanceMode.selectMode) { - _resetOffsetAnimation(_adjustedOriginalMessageOffset); - } else if (mode == ReadingAssistanceMode.practiceMode) { - _resetOffsetAnimation(_centeredMessageOffset!); - _messageSizeAnimation = Tween( - begin: Size( - _originalMessageSize.width, - _originalMessageSize.height, - ), - end: _adjustedCenteredMessageSize, - ).animate( - CurvedAnimation( - parent: _animationController, - curve: FluffyThemes.animationCurve, - ), - ); - } + // if (mode == ReadingAssistanceMode.selectMode) { + // _resetOffsetAnimation(_adjustedOriginalMessageOffset); + // } else if (mode == ReadingAssistanceMode.practiceMode) { + // _resetOffsetAnimation(_centeredMessageOffset!); + // _messageSizeAnimation = Tween( + // begin: Size( + // _originalMessageSize.width, + // _originalMessageSize.height, + // ), + // end: _adjustedCenteredMessageSize, + // ).animate( + // CurvedAnimation( + // parent: _animationController, + // curve: FluffyThemes.animationCurve, + // ), + // ); + // } - await _animationController.forward(from: 0); - if (mounted) { - setState(() => widget.overlayController.readingAssistanceMode = mode); - } - } + // await _animationController.forward(from: 0); + // if (mounted) { + // setState(() => widget.overlayController.readingAssistanceMode = mode); + // } + // } void _onContentSizeChanged(_) { Future.delayed(FluffyThemes.animationDuration, () { - final offset = _overlayMessageRenderBox?.localToGlobal(Offset.zero); - if (offset == null || !_overlayMessageRenderBox!.hasSize) { - return null; - } + setState(() {}); + // final offset = _overlayMessageRenderBox?.localToGlobal(Offset.zero); + // if (offset == null || !_overlayMessageRenderBox!.hasSize) { + // return null; + // } - final newOffset = _adjustedMessageOffset( - _overlayMessageRenderBox!.size, - offset, - ); + // final newOffset = _adjustedMessageOffset( + // _overlayMessageRenderBox!.size, + // offset, + // ); - if (newOffset == _currentOffset) return; - _resetOffsetAnimation(newOffset); - _animationController.forward(from: 0); + // if (newOffset == _currentOffset) return; + // _resetOffsetAnimation(newOffset); + // _animationController.forward(from: 0); }); } - void _resetOffsetAnimation(Offset offset) { - _overlayOffsetAnimation = Tween( - begin: _currentOffset, - end: offset, - ).animate( - CurvedAnimation( - parent: _animationController, - curve: FluffyThemes.animationCurve, - ), - )..addListener(() { - if (mounted) { - setState(() => _currentOffset = _overlayOffsetAnimation?.value); - } - }); - } + // void _resetOffsetAnimation(Offset offset) { + // _overlayOffsetAnimation = Tween( + // begin: _currentOffset, + // end: offset, + // ).animate( + // CurvedAnimation( + // parent: _animationController, + // curve: FluffyThemes.animationCurve, + // ), + // )..addListener(() { + // if (mounted) { + // setState(() => _currentOffset = _overlayOffsetAnimation?.value); + // } + // }); + // } + + // double get _inputBarSize => + // _readingAssistanceMode == ReadingAssistanceMode.practiceMode || + // _readingAssistanceMode == ReadingAssistanceMode.transitionMode + // ? AppConfig.practiceModeInputBarHeight + // : AppConfig.selectModeInputBarHeight; + + // /// Available vertical space not taken up by the header and footer + // double? get _verticalSpace { + // if (_mediaQuery == null) return null; + // return _mediaQuery!.size.height - _headerHeight - _footerHeight; + // } + + // original message size and offset + + // Offset? get _overlayMessageOffset => + // _overlayMessageRenderBox?.localToGlobal(Offset.zero); + + // double? get _buttonsTopOffset { + // if (_overlayMessageOffset == null || + // _overlayMessageSize == null || + // _mediaQuery == null) { + // return null; + // } + + // const buttonsHeight = 300.0; + // final availableSpace = _mediaQuery!.size.height - + // _overlayMessageOffset!.dy - + // _overlayMessageSize!.height - + // _reactionsHeight - + // 4.0; + + // if (availableSpace >= buttonsHeight) { + // return _overlayMessageOffset!.dy + _overlayMessageSize!.height + 4.0; + // } + + // return _mediaQuery!.size.height - buttonsHeight - 4.0; + // } + + // Centered message size and offset + + // bool get _centeredMessageHasOverflow { + // if (_verticalSpace == null || + // _centeredMessageSize == null || + // _centeredMessageOffset == null) { + // return false; + // } + + // final finalMessageHeight = _centeredMessageSize!.height + _reactionsHeight; + // return finalMessageHeight > _verticalSpace!; + // } + + // /// Size of the centered overlay message adjusted for overflow + // Size? get _adjustedCenteredMessageSize { + // if (_centeredMessageHasOverflow) { + // return Size( + // _centeredMessageSize!.width, + // _verticalSpace! - (AppConfig.toolbarSpacing * 2), + // ); + // } + // return _centeredMessageSize; + // } + + // Offset? get _adjustedCenteredMessageOffset { + // if (_centeredMessageHasOverflow) { + // return Offset( + // _centeredMessageOffset!.dx, + // _footerHeight + AppConfig.toolbarSpacing, + // ); + // } + // return _centeredMessageOffset; + // } + + // message offset + + // Offset get _adjustedOriginalMessageOffset { + // return _adjustedMessageOffset( + // _originalMessageSize, + // _originalMessageOffset, + // ); + // } + + // Offset _adjustedMessageOffset( + // Size messageSize, + // Offset messageOffset, + // ) { + // if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { + // return _defaultMessageOffset; + // } + + // final topOffset = messageOffset.dy; + // final bottomOffset = + // (_mediaQuery!.size.height - topOffset - messageSize.height) - + // _reactionsHeight - + // _selectionButtonsHeight; + + // final hasHeaderOverflow = + // topOffset < (_headerHeight + AppConfig.toolbarSpacing); + // final hasFooterOverflow = + // bottomOffset < (_footerHeight + AppConfig.toolbarSpacing); + + // if (!hasHeaderOverflow && !hasFooterOverflow) { + // return Offset( + // _ownMessage ? _messageRightOffset : _messageLeftOffset, + // bottomOffset, + // ); + // } + + // if (hasHeaderOverflow) { + // final difference = topOffset - (_headerHeight + AppConfig.toolbarSpacing); + + // double newBottomOffset = _mediaQuery!.size.height - + // topOffset + + // difference - + // messageSize.height - + // _selectionButtonsHeight; + + // if (newBottomOffset < _footerHeight + AppConfig.toolbarSpacing) { + // newBottomOffset = _footerHeight + AppConfig.toolbarSpacing; + // } + + // return Offset( + // _ownMessage ? _messageRightOffset : _messageLeftOffset, + // newBottomOffset, + // ); + // } else { + // return Offset( + // _ownMessage ? _messageRightOffset : _messageLeftOffset, + // _footerHeight + (AppConfig.toolbarSpacing * 2), + // ); + // } + // } + + // double get _originalMessageBottomOffset => + // _mediaQuery!.size.height - + // _originalMessageOffset.dy - + // _originalMessageSize.height; + + // double? get _centeredMessageTopOffset { + // if (_mediaQuery == null || + // _adjustedCenteredMessageOffset == null || + // _adjustedCenteredMessageSize == null) { + // return null; + // } + // return _mediaQuery!.size.height - + // _adjustedCenteredMessageOffset!.dy - + // _adjustedCenteredMessageSize!.height - + // _reactionsHeight; + // } + + // double get _headerHeight { + // return (Theme.of(context).appBarTheme.toolbarHeight ?? + // AppConfig.defaultHeaderHeight) + + // (_mediaQuery?.padding.top ?? 0); + // } + + // double get _footerHeight { + // return _inputBarSize + (_mediaQuery?.padding.bottom ?? 0); + // } + + // measurement for items in the toolbar + + // bool get _showButtons { + // if (!(widget.pangeaMessageEvent?.shouldShowToolbar ?? false)) { + // return false; + // } + + // final type = widget.pangeaMessageEvent?.event.messageType; + // if (![MessageTypes.Text, MessageTypes.Audio].contains(type)) { + // return false; + // } + + // if (type == MessageTypes.Text) { + // return widget.pangeaMessageEvent?.messageDisplayLangIsL2 ?? false; + // } + + // return true; + // } + + // bool get showPracticeButtons => + // _showButtons && + // widget.overlayController.readingAssistanceMode == + // ReadingAssistanceMode.practiceMode; + + // bool get showSelectionButtons => + // _showButtons && + // [ReadingAssistanceMode.selectMode, null] + // .contains(widget.overlayController.readingAssistanceMode); + + // double get _selectionButtonsHeight { + // return showSelectionButtons ? AppConfig.toolbarButtonsHeight : 0; + // } + + // double get _readingAssistanceModeOpacity { + // switch (_readingAssistanceMode) { + // case ReadingAssistanceMode.practiceMode: + // case ReadingAssistanceMode.transitionMode: + // return 0.8; + // case ReadingAssistanceMode.selectMode: + // case null: + // return 0.6; + // } + // } T _runWithLogging( Function runner, @@ -278,22 +498,27 @@ class MessageSelectionPositionerState extends State } } - ReadingAssistanceMode? get _readingAssistanceMode => - widget.overlayController.readingAssistanceMode; + double get _horizontalPadding => + FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; - double get _inputBarSize => - _readingAssistanceMode == ReadingAssistanceMode.practiceMode || - _readingAssistanceMode == ReadingAssistanceMode.transitionMode - ? AppConfig.practiceModeInputBarHeight - : AppConfig.selectModeInputBarHeight; + bool get _hasReactions { + final reactionsEvents = widget.event.aggregatedEvents( + widget.chatController.timeline!, + RelationshipTypes.reaction, + ); + return reactionsEvents.where((e) => !e.redacted).isNotEmpty; + } + + double get _reactionsHeight => _hasReactions ? 32.0 : 0.0; + + bool get _ownMessage => + widget.event.senderId == widget.event.room.client.userID; bool get _showDetails => AppSettings.displayChatDetailsColumn.getItem(Matrix.of(context).store) && FluffyThemes.isThreeColumnMode(context) && widget.chatController.room.membership == Membership.join; - // screen size - MediaQueryData? get _mediaQuery => _runWithLogging( () => MediaQuery.of(context), "Error getting media query", @@ -304,12 +529,6 @@ class MessageSelectionPositionerState extends State ? (FluffyThemes.columnWidth + FluffyThemes.navRailWidth + 1.0) : 0; - /// Available vertical space not taken up by the header and footer - double? get _verticalSpace { - if (_mediaQuery == null) return null; - return _mediaQuery!.size.height - _headerHeight - _footerHeight; - } - double get _toolbarMaxWidth { const double messageMargin = 16.0; // widget.event.isActivityMessage ? 0 : Avatar.defaultSize + 16 + 8; @@ -331,15 +550,10 @@ class MessageSelectionPositionerState extends State return maxWidth; } - // original message size and offset + static const Offset _defaultMessageOffset = + Offset(Avatar.defaultSize + 16 + 8, 300); - RenderBox? get _messageRenderBox => _runWithLogging( - () => MatrixState.pAnyState.getRenderBox( - widget.event.eventId, - ), - "Error getting message render box", - null, - ); + Size get _defaultMessageSize => const Size(FluffyThemes.columnWidth / 2, 100); RenderBox? get _overlayMessageRenderBox => _runWithLogging( () => MatrixState.pAnyState.getRenderBox( @@ -349,7 +563,26 @@ class MessageSelectionPositionerState extends State null, ); - Size get _defaultMessageSize => const Size(FluffyThemes.columnWidth / 2, 100); + Size? get _overlayMessageSize => _overlayMessageRenderBox?.size; + + RenderBox? get _messageRenderBox => _runWithLogging( + () => MatrixState.pAnyState.getRenderBox( + widget.event.eventId, + ), + "Error getting message render box", + null, + ); + + Offset get _originalMessageOffset { + if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { + return _defaultMessageOffset; + } + return _runWithLogging( + () => _messageRenderBox?.localToGlobal(Offset.zero), + "Error getting message offset", + _defaultMessageOffset, + ); + } /// The size of the message in the chat list (as opposed to the expanded size in the center overlay) Size get _originalMessageSize { @@ -364,219 +597,60 @@ class MessageSelectionPositionerState extends State ); } - static const _messageDefaultLeftMargin = Avatar.defaultSize + 16 + 8; - - // Centered message size and offset - - bool get _centeredMessageHasOverflow { - if (_verticalSpace == null || - _centeredMessageSize == null || - _centeredMessageOffset == null) { - return false; - } - - final finalMessageHeight = _centeredMessageSize!.height + _reactionsHeight; - return finalMessageHeight > _verticalSpace!; + double? get _messageLeftOffset { + if (_ownMessage) return null; + return max(_originalMessageOffset.dx - _columnWidth, 0); } - /// Size of the centered overlay message adjusted for overflow - Size? get _adjustedCenteredMessageSize { - if (_centeredMessageHasOverflow) { - return Size( - _centeredMessageSize!.width, - _verticalSpace! - (AppConfig.toolbarSpacing * 2), - ); - } - return _centeredMessageSize; - } - - Offset? get _adjustedCenteredMessageOffset { - if (_centeredMessageHasOverflow) { - return Offset( - _centeredMessageOffset!.dx, - _footerHeight + AppConfig.toolbarSpacing, - ); - } - return _centeredMessageOffset; - } - - // message offset - - static const Offset _defaultMessageOffset = - Offset(_messageDefaultLeftMargin, 300); - - Offset get _originalMessageOffset { - if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { - return _defaultMessageOffset; - } - return _runWithLogging( - () => _messageRenderBox?.localToGlobal(Offset.zero), - "Error getting message offset", - _defaultMessageOffset, - ); - } - - Offset get _adjustedOriginalMessageOffset { - return _adjustedMessageOffset( - _originalMessageSize, - _originalMessageOffset, - ); - } - - Offset _adjustedMessageOffset( - Size messageSize, - Offset messageOffset, - ) { - if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { - return _defaultMessageOffset; - } - - final topOffset = messageOffset.dy; - final bottomOffset = - (_mediaQuery!.size.height - topOffset - messageSize.height) - - _reactionsHeight - - _selectionButtonsHeight; - - final hasHeaderOverflow = - topOffset < (_headerHeight + AppConfig.toolbarSpacing); - final hasFooterOverflow = - bottomOffset < (_footerHeight + AppConfig.toolbarSpacing); - - if (!hasHeaderOverflow && !hasFooterOverflow) { - return Offset( - _ownMessage ? _messageRightOffset : _messageLeftOffset, - bottomOffset, - ); - } - - if (hasHeaderOverflow) { - final difference = topOffset - (_headerHeight + AppConfig.toolbarSpacing); - - double newBottomOffset = _mediaQuery!.size.height - - topOffset + - difference - - messageSize.height - - _selectionButtonsHeight; - - if (newBottomOffset < _footerHeight + AppConfig.toolbarSpacing) { - newBottomOffset = _footerHeight + AppConfig.toolbarSpacing; - } - - return Offset( - _ownMessage ? _messageRightOffset : _messageLeftOffset, - newBottomOffset, - ); - } else { - return Offset( - _ownMessage ? _messageRightOffset : _messageLeftOffset, - _footerHeight + (AppConfig.toolbarSpacing * 2), - ); - } - } - - double get _originalMessageBottomOffset => - _mediaQuery!.size.height - - _originalMessageOffset.dy - - _originalMessageSize.height; - - double? get _centeredMessageTopOffset { - if (_mediaQuery == null || - _adjustedCenteredMessageOffset == null || - _adjustedCenteredMessageSize == null) { - return null; - } - return _mediaQuery!.size.height - - _adjustedCenteredMessageOffset!.dy - - _adjustedCenteredMessageSize!.height - - _reactionsHeight; - } - - double get _messageLeftOffset => max( - _originalMessageOffset.dx - _columnWidth - _horizontalPadding, - 0, - ); - - double get _messageRightOffset { - if (_mediaQuery == null || !_ownMessage) { - return 0; - } + double? get _messageRightOffset { + if (_mediaQuery == null || !_ownMessage) return null; return _mediaQuery!.size.width - _originalMessageOffset.dx - _originalMessageSize.width - - _horizontalPadding - (_showDetails ? FluffyThemes.columnWidth : 0); } - // measurements for items around the toolbar - - double get _horizontalPadding => - FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; - - double get _headerHeight { - return (Theme.of(context).appBarTheme.toolbarHeight ?? - AppConfig.defaultHeaderHeight) + - (_mediaQuery?.padding.top ?? 0); + double? get _contentHeight { + if (_overlayMessageSize == null) return null; + return _overlayMessageSize!.height + + _reactionsHeight + + AppConfig.toolbarMenuHeight + + 4.0; } - double get _footerHeight { - return _inputBarSize + (_mediaQuery?.padding.bottom ?? 0); + double get _overheadContentHeight { + return widget.pangeaMessageEvent != null && + widget.overlayController.selectedToken != null + ? AppConfig.toolbarMaxHeight + : 40.0; } - // measurement for items in the toolbar + double? get _availableSpaceAboveContent { + if (_contentHeight == null || _mediaQuery == null) return null; + return max(0, (_mediaQuery!.size.height - _contentHeight!) / 2); + } - bool get _showButtons { - if (!(widget.pangeaMessageEvent?.shouldShowToolbar ?? false)) { - return false; + double? get _wordCardTopOffset { + if (_contentHeight == null || _availableSpaceAboveContent == null) { + return null; } - final type = widget.pangeaMessageEvent?.event.messageType; - if (![MessageTypes.Text, MessageTypes.Audio].contains(type)) { - return false; + if (_availableSpaceAboveContent! >= _overheadContentHeight) { + return _availableSpaceAboveContent! - _overheadContentHeight - 4.0; } - if (type == MessageTypes.Text) { - return widget.pangeaMessageEvent?.messageDisplayLangIsL2 ?? false; - } - - return true; - } - - bool get showPracticeButtons => - _showButtons && - widget.overlayController.readingAssistanceMode == - ReadingAssistanceMode.practiceMode; - - bool get showSelectionButtons => - _showButtons && - [ReadingAssistanceMode.selectMode, null] - .contains(widget.overlayController.readingAssistanceMode); - - double get _selectionButtonsHeight { - return showSelectionButtons ? AppConfig.toolbarButtonsHeight : 0; - } - - bool get _hasReactions { - final reactionsEvents = widget.event.aggregatedEvents( - widget.chatController.timeline!, - RelationshipTypes.reaction, - ); - return reactionsEvents.where((e) => !e.redacted).isNotEmpty; - } - - double get _reactionsHeight => _hasReactions ? 28 : 0; - - bool get _ownMessage => - widget.event.senderId == widget.event.room.client.userID; - - double get _readingAssistanceModeOpacity { - switch (_readingAssistanceMode) { - case ReadingAssistanceMode.practiceMode: - case ReadingAssistanceMode.transitionMode: - return 0.8; - case ReadingAssistanceMode.selectMode: - case null: - return 0.6; + return 0; + } + + double? get _wordCardLeftOffset { + if (_ownMessage) return null; + if (widget.pangeaMessageEvent != null && + widget.overlayController.selectedToken != null && + _mediaQuery != null && + (_mediaQuery!.size.width < _toolbarMaxWidth + _messageLeftOffset!)) { + return _mediaQuery!.size.width - _toolbarMaxWidth - 8.0; } + return _messageLeftOffset; } @override @@ -586,226 +660,274 @@ class MessageSelectionPositionerState extends State } widget.overlayController.maxWidth = _toolbarMaxWidth; - - return Stack( + return Row( children: [ - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - duration: _animationDuration, - opacity: _readingAssistanceModeOpacity, - child: Container( - height: double.infinity, - width: double.infinity, - color: Colors.black, - ), - ), - ), - ), - Padding( - padding: EdgeInsets.only( - left: _horizontalPadding, - right: _horizontalPadding, - ), - child: Row( - children: [ - Expanded( + Column( + children: [ + Expanded( + child: SizedBox( + width: _mediaQuery!.size.width - + _columnWidth - + (_showDetails ? FluffyThemes.columnWidth : 0), child: Stack( - alignment: Alignment.center, + alignment: _ownMessage + ? Alignment.centerRight + : Alignment.centerLeft, children: [ - Column( - children: [ - Material( - type: MaterialType.transparency, - child: Column( - children: [ - SizedBox(height: _mediaQuery?.padding.top ?? 0), - OverlayHeader(controller: widget.chatController), - ], - ), + GestureDetector( + onTap: widget.chatController.clearSelectedEvents, + child: SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.only( + left: _messageLeftOffset ?? 0.0, + right: _messageRightOffset ?? 0.0, ), - const Expanded( - flex: 3, - child: SizedBox.shrink(), - ), - Opacity( - opacity: _readingAssistanceMode == - ReadingAssistanceMode.practiceMode - ? 1.0 - : 0.0, - child: OverlayCenterContent( - event: widget.event, - messageHeight: null, - messageWidth: null, - maxWidth: widget.overlayController.maxWidth, - overlayController: widget.overlayController, - chatController: widget.chatController, - pangeaMessageEvent: widget.pangeaMessageEvent, - nextEvent: widget.nextEvent, - prevEvent: widget.prevEvent, - hasReactions: _hasReactions, - onChangeMessageSize: _setCenteredMessageSize, - isTransitionAnimation: false, - maxHeight: _mediaQuery!.size.height - - _headerHeight - - _footerHeight - - AppConfig.toolbarSpacing * 2 - - _selectionButtonsHeight, - readingAssistanceMode: _readingAssistanceMode, - ), - ), - const Expanded( - flex: 1, - child: SizedBox.shrink(), - ), - Row( + child: Column( + crossAxisAlignment: _ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - OverlayFooter( - controller: widget.chatController, - overlayController: widget.overlayController, - showToolbarButtons: showPracticeButtons, - readingAssistanceMode: - _readingAssistanceMode, - ), - SizedBox( - height: _mediaQuery?.padding.bottom ?? 0, - ), - ], + if (_contentHeight != null && + _mediaQuery != null && + _availableSpaceAboveContent != null && + _availableSpaceAboveContent! < + _overheadContentHeight) + AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: + _contentHeight! + _overheadContentHeight > + _mediaQuery!.size.height + ? _overheadContentHeight + : (_overheadContentHeight - + _availableSpaceAboveContent!) * + 2, ), + CompositedTransformTarget( + link: MatrixState.pAnyState + .layerLinkAndKey( + 'overlay_message_${widget.event.eventId}', + ) + .link, + child: OverlayCenterContent( + event: widget.event, + messageHeight: _originalMessageSize.height, + messageWidth: + widget.overlayController.showingExtraContent + ? max(_originalMessageSize.width, 150) + : _originalMessageSize.width, + overlayController: widget.overlayController, + chatController: widget.chatController, + nextEvent: widget.nextEvent, + prevEvent: widget.prevEvent, + hasReactions: _hasReactions, + // sizeAnimation: _messageSizeAnimation, + isTransitionAnimation: true, + readingAssistanceMode: widget + .overlayController.readingAssistanceMode, + ), + ), + const SizedBox(height: 4.0), + SelectModeButtons( + controller: widget.chatController, + overlayController: widget.overlayController, + lauchPractice: () {}, + // lauchPractice: () { + // _setReadingAssistanceMode( + // ReadingAssistanceMode.practiceMode, + // ); + // widget.overlayController + // .updateSelectedSpan(null); + // }, ), ], ), - ], + ), ), - if (_readingAssistanceMode != - ReadingAssistanceMode.practiceMode && - _readingAssistanceMode != null) - AnimatedBuilder( - animation: - _overlayOffsetAnimation ?? _animationController, - builder: (context, child) { - return Positioned( - left: _ownMessage - ? null - : (_overlayOffsetAnimation?.value)?.dx ?? - _messageLeftOffset, - right: _ownMessage - ? (_overlayOffsetAnimation?.value)?.dx ?? - _messageRightOffset - : null, - bottom: (_overlayOffsetAnimation?.value)?.dy ?? - _originalMessageBottomOffset - - _reactionsHeight - - _selectionButtonsHeight, - child: Column( - crossAxisAlignment: _ownMessage - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - OverlayCenterContent( - event: widget.event, - messageHeight: _originalMessageSize.height, - messageWidth: widget - .overlayController.showingExtraContent - ? max(_originalMessageSize.width, 150) - : _originalMessageSize.width, - maxWidth: widget.overlayController.maxWidth, - overlayController: widget.overlayController, - chatController: widget.chatController, - pangeaMessageEvent: widget.pangeaMessageEvent, - nextEvent: widget.nextEvent, - prevEvent: widget.prevEvent, - hasReactions: _hasReactions, - sizeAnimation: _messageSizeAnimation, - isTransitionAnimation: true, - maxHeight: _mediaQuery!.size.height - - _headerHeight - - _footerHeight - - AppConfig.toolbarSpacing * 2 - - _selectionButtonsHeight, - readingAssistanceMode: _readingAssistanceMode, - ), - if (showSelectionButtons) - SelectModeButtons( + AnimatedPositioned( + top: _wordCardTopOffset, + left: _wordCardLeftOffset, + right: _messageRightOffset, + duration: FluffyThemes.animationDuration, + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _wordCardTopOffset == null + ? const SizedBox() + : widget.pangeaMessageEvent != null && + widget.overlayController.selectedToken != + null + ? ReadingAssistanceContent( + pangeaMessageEvent: + widget.pangeaMessageEvent!, overlayController: widget.overlayController, - lauchPractice: () { - _setReadingAssistanceMode( - ReadingAssistanceMode.practiceMode, - ); - widget.overlayController - .updateSelectedSpan(null); - }, + ) + : MessageReactionPicker( + chatController: widget.chatController, ), - ], - ), - ); - }, - ), - if (showPracticeButtons) - Positioned( - top: 0, - child: IgnorePointer( - child: MeasureRenderBox( - onChange: _setTooltipSize, - child: Opacity( - opacity: 0.0, - child: Container( - constraints: BoxConstraints( - minWidth: 200.0, - maxWidth: _toolbarMaxWidth, - ), - child: InstructionsInlineTooltip( - instructionsEnum: widget.overlayController - .toolbarMode.instructionsEnum ?? - InstructionsEnum - .readingAssistanceOverview, - bold: true, - ), - ), - ), - ), - ), - ), - if (_centeredMessageTopOffset != null && - _tooltipSize != null && - widget.overlayController.toolbarMode != - MessageMode.noneSelected && - widget.overlayController.selectedToken == null) - Positioned( - top: max( - ((_headerHeight + _centeredMessageTopOffset!) / 2) - - (_tooltipSize!.height / 2), - _headerHeight, - ), - child: Container( - constraints: BoxConstraints( - minWidth: 200.0, - maxWidth: widget.overlayController.maxWidth, - ), - child: InstructionsInlineTooltip( - instructionsEnum: widget.overlayController - .toolbarMode.instructionsEnum ?? - InstructionsEnum.readingAssistanceOverview, - bold: true, - ), - ), ), + ), ], ), ), - if (_showDetails) - const SizedBox( - width: FluffyThemes.columnWidth, - ), - ], - ), + ), + ], ), + if (_showDetails) + const SizedBox( + width: FluffyThemes.columnWidth, + ), ], ); } } + +class MessageReactionPicker extends StatelessWidget { + final ChatController chatController; + const MessageReactionPicker({ + super.key, + required this.chatController, + }); + + @override + Widget build(BuildContext context) { + if (chatController.selectedEvents.length != 1) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final sentReactions = {}; + final event = chatController.selectedEvents.first; + sentReactions.addAll( + event + .aggregatedEvents( + chatController.timeline!, + RelationshipTypes.reaction, + ) + .where( + (event) => + event.senderId == event.room.client.userID && + event.type == 'm.reaction', + ) + .map( + (event) => event.content + .tryGetMap('m.relates_to') + ?.tryGet('key'), + ) + .whereType(), + ); + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + shadowColor: theme.colorScheme.surface.withAlpha(128), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...AppConfig.defaultReactions.map( + (emoji) => IconButton( + padding: EdgeInsets.zero, + icon: Center( + child: Opacity( + opacity: sentReactions.contains( + emoji, + ) + ? 0.33 + : 1, + child: Text( + emoji, + style: const TextStyle( + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + ), + ), + onPressed: sentReactions.contains(emoji) + ? null + : () => event.room.sendReaction( + event.eventId, + emoji, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.add_reaction_outlined, + ), + tooltip: L10n.of(context).customReaction, + onPressed: () async { + final emoji = await showAdaptiveBottomSheet( + context: context, + builder: (context) => Scaffold( + appBar: AppBar( + title: Text( + L10n.of(context).customReaction, + ), + leading: CloseButton( + onPressed: () => Navigator.of( + context, + ).pop( + null, + ), + ), + ), + body: SizedBox( + height: double.infinity, + child: EmojiPicker( + onEmojiSelected: ( + _, + emoji, + ) => + Navigator.of( + context, + ).pop( + emoji.emoji, + ), + config: Config( + emojiViewConfig: const EmojiViewConfig( + backgroundColor: Colors.transparent, + ), + bottomActionBarConfig: const BottomActionBarConfig( + enabled: false, + ), + categoryViewConfig: CategoryViewConfig( + initCategory: Category.SMILEYS, + backspaceColor: theme.colorScheme.primary, + iconColor: theme.colorScheme.primary.withAlpha( + 128, + ), + iconColorSelected: theme.colorScheme.primary, + indicatorColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surface, + ), + skinToneConfig: SkinToneConfig( + dialogBackgroundColor: Color.lerp( + theme.colorScheme.surface, + theme.colorScheme.primaryContainer, + 0.75, + )!, + indicatorColor: theme.colorScheme.onSurface, + ), + ), + ), + ), + ), + ); + if (emoji == null) return; + if (sentReactions.contains(emoji)) return; + await event.room.sendReaction( + event.eventId, + emoji, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/toolbar/widgets/overlay_center_content.dart b/lib/pangea/toolbar/widgets/overlay_center_content.dart index 6b5396b65..aa23be54f 100644 --- a/lib/pangea/toolbar/widgets/overlay_center_content.dart +++ b/lib/pangea/toolbar/widgets/overlay_center_content.dart @@ -4,7 +4,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/measure_render_box.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; @@ -15,7 +14,6 @@ class OverlayCenterContent extends StatelessWidget { final Event event; final Event? nextEvent; final Event? prevEvent; - final PangeaMessageEvent? pangeaMessageEvent; final MessageOverlayController overlayController; final ChatController chatController; @@ -25,8 +23,6 @@ class OverlayCenterContent extends StatelessWidget { final double? messageHeight; final double? messageWidth; - final double maxWidth; - final double maxHeight; final bool hasReactions; @@ -37,11 +33,8 @@ class OverlayCenterContent extends StatelessWidget { required this.event, required this.messageHeight, required this.messageWidth, - required this.maxWidth, - required this.maxHeight, required this.overlayController, required this.chatController, - required this.pangeaMessageEvent, required this.nextEvent, required this.prevEvent, required this.hasReactions, @@ -58,7 +51,7 @@ class OverlayCenterContent extends StatelessWidget { ignoring: !isTransitionAnimation && readingAssistanceMode != ReadingAssistanceMode.practiceMode, child: Container( - constraints: BoxConstraints(maxWidth: maxWidth), + constraints: BoxConstraints(maxWidth: overlayController.maxWidth), child: Material( type: MaterialType.transparency, child: Column( @@ -76,7 +69,6 @@ class OverlayCenterContent extends StatelessWidget { .key : null, event, - pangeaMessageEvent: pangeaMessageEvent, immersionMode: chatController.choreographer.immersionMode, controller: chatController, overlayController: overlayController, @@ -93,7 +85,6 @@ class OverlayCenterContent extends StatelessWidget { (sizeAnimation == null && isTransitionAnimation) ? messageHeight : null, - maxHeight: maxHeight, isTransitionAnimation: isTransitionAnimation, readingAssistanceMode: readingAssistanceMode, ), diff --git a/lib/pangea/toolbar/widgets/overlay_message.dart b/lib/pangea/toolbar/widgets/overlay_message.dart index e678ac1ad..47269e6fc 100644 --- a/lib/pangea/toolbar/widgets/overlay_message.dart +++ b/lib/pangea/toolbar/widgets/overlay_message.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; @@ -12,7 +11,6 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_content.dart'; import 'package:fluffychat/pages/chat/events/reply_content.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; @@ -28,7 +26,6 @@ import 'package:fluffychat/widgets/matrix.dart'; // @ggurdin be great to explain the need/function of a widget like this class OverlayMessage extends StatelessWidget { final Event event; - final PangeaMessageEvent? pangeaMessageEvent; final MessageOverlayController overlayController; final ChatController controller; final Event? nextEvent; @@ -39,7 +36,6 @@ class OverlayMessage extends StatelessWidget { final Animation? sizeAnimation; final double? messageWidth; final double? messageHeight; - final double maxHeight; final bool isTransitionAnimation; final ReadingAssistanceMode? readingAssistanceMode; @@ -52,8 +48,6 @@ class OverlayMessage extends StatelessWidget { required this.timeline, required this.messageWidth, required this.messageHeight, - required this.maxHeight, - this.pangeaMessageEvent, this.nextEvent, this.previousEvent, this.sizeAnimation, @@ -146,7 +140,8 @@ class OverlayMessage extends StatelessWidget { final showTranslation = overlayController.showTranslation && overlayController.translation != null; - final showTranscription = pangeaMessageEvent?.isAudioMessage == true; + final showTranscription = + overlayController.pangeaMessageEvent?.isAudioMessage == true; final showSpeechTranslation = overlayController.showSpeechTranslation && overlayController.speechTranslation != null; @@ -158,7 +153,10 @@ class OverlayMessage extends StatelessWidget { FluffyThemes.columnWidth * 1.5, MediaQuery.of(context).size.width - (ownMessage ? 0 : Avatar.defaultSize) - - 24.0, + 32.0 - + (FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth + : 0.0), ), ), child: Padding( @@ -243,7 +241,10 @@ class OverlayMessage extends StatelessWidget { FluffyThemes.columnWidth * 1.5, MediaQuery.of(context).size.width - (ownMessage ? 0 : Avatar.defaultSize) - - 24.0, + 32.0 - + (FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth + : 0.0), ), ), child: Padding( @@ -278,115 +279,109 @@ class OverlayMessage extends StatelessWidget { ), width: messageWidth, height: messageHeight, - constraints: BoxConstraints( - maxHeight: maxHeight, - ), - child: SingleChildScrollView( - dragStartBehavior: DragStartBehavior.down, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (event.relationshipType == RelationshipTypes.reply) - FutureBuilder( - future: event.getReplyEvent( - timeline, - ), - builder: ( - BuildContext context, - snapshot, - ) { - final replyEvent = snapshot.hasData - ? snapshot.data! - : Event( - eventId: event.relationshipEventId!, - content: { - 'msgtype': 'm.text', - 'body': '...', - }, - senderId: "", - type: 'm.room.message', - room: event.room, - status: EventStatus.sent, - originServerTs: DateTime.now(), - ); - return Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - ), - child: Material( - color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.relationshipType == RelationshipTypes.reply) + FutureBuilder( + future: event.getReplyEvent( + timeline, + ), + builder: ( + BuildContext context, + snapshot, + ) { + final replyEvent = snapshot.hasData + ? snapshot.data! + : Event( + eventId: event.relationshipEventId!, + content: { + 'msgtype': 'm.text', + 'body': '...', + }, + senderId: "", + type: 'm.room.message', + room: event.room, + status: EventStatus.sent, + originServerTs: DateTime.now(), + ); + return Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + ), + child: Material( + color: Colors.transparent, + borderRadius: ReplyContent.borderRadius, + child: InkWell( borderRadius: ReplyContent.borderRadius, - child: InkWell( - borderRadius: ReplyContent.borderRadius, - onTap: () => controller.scrollToEventId( - replyEvent.eventId, - ), - child: AbsorbPointer( - child: ReplyContent( - replyEvent, - ownMessage: ownMessage, - timeline: timeline, - ), + onTap: () => controller.scrollToEventId( + replyEvent.eventId, + ), + child: AbsorbPointer( + child: ReplyContent( + replyEvent, + ownMessage: ownMessage, + timeline: timeline, ), ), ), - ); - }, - ), - MessageContent( - displayEvent, - textColor: textColor, - linkColor: linkColor, - borderRadius: borderRadius, - timeline: timeline, - pangeaMessageEvent: pangeaMessageEvent, - immersionMode: immersionMode, - overlayController: overlayController, - controller: controller, - nextEvent: nextEvent, - prevEvent: previousEvent, - isTransitionAnimation: isTransitionAnimation, - readingAssistanceMode: readingAssistanceMode, - selected: true, + ), + ); + }, ), - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.edit, - )) - Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - left: 16.0, - right: 16.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [ - Icon( - Icons.edit_outlined, - color: textColor.withAlpha(164), - size: 14, - ), - Text( - displayEvent.originServerTs.localizedTimeShort( - context, - ), - style: TextStyle( - color: textColor.withAlpha( - 164, - ), - fontSize: 11, - ), - ), - ], - ), + MessageContent( + displayEvent, + textColor: textColor, + linkColor: linkColor, + borderRadius: borderRadius, + timeline: timeline, + pangeaMessageEvent: overlayController.pangeaMessageEvent, + immersionMode: immersionMode, + overlayController: overlayController, + controller: controller, + nextEvent: nextEvent, + prevEvent: previousEvent, + isTransitionAnimation: isTransitionAnimation, + readingAssistanceMode: readingAssistanceMode, + selected: true, + ), + if (event.hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + )) + Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + left: 16.0, + right: 16.0, ), - ], - ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + Icon( + Icons.edit_outlined, + color: textColor.withAlpha(164), + size: 14, + ), + Text( + displayEvent.originServerTs.localizedTimeShort( + context, + ), + style: TextStyle( + color: textColor.withAlpha( + 164, + ), + fontSize: 11, + ), + ), + ], + ), + ), + ], ), ); @@ -398,9 +393,8 @@ class OverlayMessage extends StatelessWidget { color: noBubble ? Colors.transparent : color, borderRadius: borderRadius, ), - constraints: BoxConstraints( + constraints: const BoxConstraints( maxWidth: FluffyThemes.columnWidth * 1.5, - maxHeight: maxHeight, ), child: SingleChildScrollView( child: Column( diff --git a/lib/pangea/toolbar/widgets/reading_assistance_content.dart b/lib/pangea/toolbar/widgets/reading_assistance_content.dart index 0edc58849..ff0cadfd9 100644 --- a/lib/pangea/toolbar/widgets/reading_assistance_content.dart +++ b/lib/pangea/toolbar/widgets/reading_assistance_content.dart @@ -120,6 +120,8 @@ class ReadingAssistanceContentState extends State { token: widget.overlayController.selectedToken!, messageEvent: widget.overlayController.pangeaMessageEvent!, overlayController: widget.overlayController, + wordIsNew: widget.overlayController + .isNewToken(widget.overlayController.selectedToken!), ); } } @@ -147,14 +149,13 @@ class ReadingAssistanceContentState extends State { ), ), constraints: BoxConstraints( - maxHeight: AppConfig.toolbarMaxHeight, minWidth: min( AppConfig.toolbarMinWidth, widget.overlayController.maxWidth, ), - minHeight: AppConfig.toolbarMinHeight, maxWidth: widget.overlayController.maxWidth, ), + height: AppConfig.toolbarMaxHeight, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/pangea/toolbar/widgets/select_mode_buttons.dart b/lib/pangea/toolbar/widgets/select_mode_buttons.dart index 1a5ff426b..91ded7d9f 100644 --- a/lib/pangea/toolbar/widgets/select_mode_buttons.dart +++ b/lib/pangea/toolbar/widgets/select_mode_buttons.dart @@ -11,12 +11,13 @@ import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; +import 'package:fluffychat/pangea/events/utils/report_message.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; @@ -45,13 +46,74 @@ enum SelectMode { } } +enum MessageActions { + reply, + forward, + edit, + delete, + copy, + download, + pin, + report, + info; + + IconData get icon { + switch (this) { + case MessageActions.reply: + return Icons.reply_all; + case MessageActions.forward: + return Symbols.forward; + case MessageActions.edit: + return Symbols.edit; + case MessageActions.delete: + return Symbols.delete; + case MessageActions.copy: + return Icons.copy_outlined; + case MessageActions.download: + return Symbols.download; + case MessageActions.pin: + return Symbols.push_pin; + case MessageActions.report: + return Icons.shield_outlined; + case MessageActions.info: + return Icons.info_outlined; + } + } + + String tooltip(BuildContext context) { + final l10n = L10n.of(context); + switch (this) { + case MessageActions.reply: + return l10n.reply; + case MessageActions.forward: + return l10n.forward; + case MessageActions.edit: + return l10n.edit; + case MessageActions.delete: + return l10n.redactMessage; + case MessageActions.copy: + return l10n.copy; + case MessageActions.download: + return l10n.download; + case MessageActions.pin: + return l10n.pinMessage; + case MessageActions.report: + return l10n.reportMessage; + case MessageActions.info: + return l10n.messageInfo; + } + } +} + class SelectModeButtons extends StatefulWidget { final VoidCallback lauchPractice; final MessageOverlayController overlayController; + final ChatController controller; const SelectModeButtons({ required this.lauchPractice, required this.overlayController, + required this.controller, super.key, }); @@ -475,46 +537,172 @@ class SelectModeButtonsState extends State { ); } + bool _messageActionEnabled(MessageActions action) { + if (messageEvent == null) return false; + + switch (action) { + case MessageActions.reply: + return widget.controller.selectedEvents.length == 1 && + widget.controller.room.canSendDefaultMessages; + case MessageActions.edit: + return widget.controller.canEditSelectedEvents && + !widget.controller.selectedEvents.first.isActivityMessage; + case MessageActions.delete: + return widget.controller.canRedactSelectedEvents; + case MessageActions.copy: + return widget.controller.selectedEvents.length == 1 && + widget.controller.selectedEvents.single.messageType == + MessageTypes.Text; + case MessageActions.download: + return widget.controller.canSaveSelectedEvent; + case MessageActions.pin: + return widget.controller.canPinSelectedEvents; + case MessageActions.forward: + case MessageActions.report: + case MessageActions.info: + return widget.controller.selectedEvents.length == 1; + } + } + + void _onActionPressed(MessageActions action) { + switch (action) { + case MessageActions.reply: + widget.controller.replyAction(); + break; + case MessageActions.forward: + widget.controller.forwardEventsAction(); + break; + case MessageActions.edit: + widget.controller.editSelectedEventAction(); + break; + case MessageActions.delete: + widget.controller.redactEventsAction(); + break; + case MessageActions.copy: + widget.controller.copyEventsAction(); + break; + case MessageActions.download: + widget.controller.saveSelectedEvent(context); + break; + case MessageActions.pin: + widget.controller.pinEvent(); + break; + case MessageActions.report: + final event = widget.controller.selectedEvents.first; + widget.controller.clearSelectedEvents(); + reportEvent( + event, + widget.controller, + widget.controller.context, + ); + break; + case MessageActions.info: + widget.controller.showEventInfo(); + widget.controller.clearSelectedEvents(); + break; + } + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); final modes = messageEvent?.isAudioMessage == true ? audioModes : textModes; + final actions = MessageActions.values.where(_messageActionEnabled); - return Container( - height: AppConfig.toolbarButtonsHeight, - alignment: Alignment.bottomCenter, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [ - for (final mode in modes) - TooltipVisibility( - visible: (!_isError || mode != _selectedMode), - child: Tooltip( - message: mode.tooltip(context), - child: PressableButton( - depressed: mode == _selectedMode, - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.primaryContainer, - onPressed: () => _updateMode(mode), - playSound: mode != SelectMode.audio, - colorFactor: Theme.of(context).brightness == Brightness.light - ? 0.55 - : 0.3, - child: Container( - height: buttonSize, - width: buttonSize, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: icon(mode), - ), + return Material( + type: MaterialType.transparency, + child: Container( + width: 250, + constraints: const BoxConstraints( + maxHeight: AppConfig.toolbarMenuHeight, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: modes.length + actions.length + 1, + itemBuilder: (context, index) { + if (index < modes.length) { + final mode = modes[index]; + return SizedBox( + height: 50.0, + child: ListTile( + leading: Icon(mode.icon), + title: Text(mode.tooltip(context)), + onTap: () => _updateMode(mode), ), - ), - ), - ], + ); + } else if (index == modes.length) { + return const Divider(height: 1.0); + } else { + final action = actions.elementAt(index - modes.length - 1); + return SizedBox( + height: 50.0, + child: ListTile( + leading: Icon(action.icon), + title: Text(action.tooltip(context)), + onTap: () => _onActionPressed(action), + ), + ); + } + }, + ), ), ); + + // return SizedBox( + // width: 150, + // child: ListView.builder( + // itemCount: modes.length, + // itemBuilder: (context, index) { + // final mode = modes[index]; + // return ListTile( + // leading: Icon(mode.icon), + // title: Text(mode.name), + // onTap: () { + // _updateMode(mode); + // }, + // ); + // }, + // ), + // ); + + // return Row( + // crossAxisAlignment: CrossAxisAlignment.center, + // mainAxisSize: MainAxisSize.min, + // spacing: 4.0, + // children: [ + // for (final mode in modes) + // TooltipVisibility( + // visible: (!_isError || mode != _selectedMode), + // child: Tooltip( + // message: mode.tooltip(context), + // child: PressableButton( + // depressed: mode == _selectedMode, + // borderRadius: BorderRadius.circular(20), + // color: Theme.of(context).colorScheme.primaryContainer, + // onPressed: () => _updateMode(mode), + // playSound: mode != SelectMode.audio, + // colorFactor: Theme.of(context).brightness == Brightness.light + // ? 0.55 + // : 0.3, + // child: Container( + // height: buttonSize, + // width: buttonSize, + // decoration: BoxDecoration( + // color: Theme.of(context).colorScheme.primaryContainer, + // shape: BoxShape.circle, + // ), + // child: icon(mode), + // ), + // ), + // ), + // ), + // ], + // ); } } diff --git a/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart b/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart index 7e2be07f9..2aede4816 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_edit_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; @@ -84,14 +86,34 @@ class LemmaMeaningBuilderState extends State { void toggleEditMode(bool value) => setState(() => editMode = value); Future editLemmaMeaning(String userEdit) async { - final originalMeaning = lemmaInfo; - - if (originalMeaning != null) { - LemmaInfoRepo.set( - _request, - LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit), + try { + await LemmaInfoRepo.edit( + LemmaEditRequest( + lemma: widget.constructId.lemma, + partOfSpeech: widget.constructId.category, + lemmaLang: widget.langCode, + userL1: MatrixState + .pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + newMeaning: userEdit, + newEmojis: lemmaInfo?.emoji, + ), ); - + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'lemma': widget.constructId.lemma, + 'partOfSpeech': widget.constructId.category, + 'lemmaLang': widget.langCode, + 'userL1': MatrixState + .pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + 'newMeaning': userEdit, + }, + ); + } finally { toggleEditMode(false); _fetchLemmaMeaning(); } diff --git a/lib/pangea/toolbar/widgets/word_zoom/morphological_list_item.dart b/lib/pangea/toolbar/widgets/word_zoom/morphological_list_item.dart index fef3814c1..579c65e1c 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/morphological_list_item.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/morphological_list_item.dart @@ -3,11 +3,11 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; @@ -278,17 +278,9 @@ class MorphMeaningPopupState extends State { null) ConstructXpWidget( id: widget.cId, - onTap: () => showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - constructZoom: widget.cId, - view: ConstructTypeEnum.morph, - backButtonOverride: IconButton( - icon: const Icon(Icons.close), - onPressed: () => - Navigator.of(context).pop(), - ), - ), + onTap: () => context.go( + "/rooms/analytics?mode=morph", + extra: widget.cId, ), ), ], diff --git a/lib/pangea/toolbar/widgets/word_zoom/new_word_overlay.dart b/lib/pangea/toolbar/widgets/word_zoom/new_word_overlay.dart new file mode 100644 index 000000000..a1b893b53 --- /dev/null +++ b/lib/pangea/toolbar/widgets/word_zoom/new_word_overlay.dart @@ -0,0 +1,158 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; + +class NewWordOverlay extends StatefulWidget { + final Color overlayColor; + final GlobalKey cardKey; + + const NewWordOverlay({ + super.key, + required this.overlayColor, + required this.cardKey, + }); + + @override + State createState() => _NewWordOverlayState(); +} + +class _NewWordOverlayState extends State + with TickerProviderStateMixin { + AnimationController? _controller; + Animation? _xpScaleAnim; + Animation? _fadeAnim; + Size cardSize = const Size(0, 0); + Offset cardPosition = const Offset(0, 0); + OverlayEntry? _overlayEntry; + bool columnMode = false; + Widget? get svg => ConstructLevelEnum.seeds.icon(); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1700), + ); + _xpScaleAnim = CurvedAnimation( + parent: _controller!, + curve: const Interval(0.0, 0.6, curve: Curves.easeInOut), + ); + _fadeAnim = CurvedAnimation( + parent: _controller!, + curve: const Interval(0.7, 1.0, curve: Curves.easeOut), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + columnMode = FluffyThemes.isColumnMode(context); + calculateSizeAndPosition(); + _showFlyingWidget(); + _controller?.forward(); + }); + } + + @override + void dispose() { + _overlayEntry?.remove(); + _controller?.dispose(); + super.dispose(); + } + + void calculateSizeAndPosition() { + //find position of word card and overlaybox(chat view) to figure out where seed should start + final RenderBox? cardBox = + widget.cardKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? overlayBox = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (cardBox != null && overlayBox != null) { + final cardGlobal = cardBox.localToGlobal(Offset.zero); + final overlayGlobal = overlayBox.localToGlobal(Offset.zero); + setState(() { + cardPosition = cardGlobal - overlayGlobal; + cardSize = cardBox.size; + }); + } + } + + void _showFlyingWidget() { + _overlayEntry?.remove(); // Remove any existing overlay + if (_controller == null || _xpScaleAnim == null || _fadeAnim == null) { + return; + } + _overlayEntry = OverlayEntry( + builder: (context) => AnimatedBuilder( + animation: _controller!, + builder: (context, child) { + final scale = _xpScaleAnim!.value; + final fade = 1.0 - (_fadeAnim!.value); + double t = 0.0; + if ((_controller!.value) >= 0.7) { + t = ((_controller!.value) - 0.7) / 0.3; + t = t.clamp(0.0, 1.0); + } + //move starting position as seed grows so it stays centered + final seedSize = 75 * scale * ((!columnMode) ? fade : 1); + final startX = cardPosition.dx + cardSize.width / 2 - seedSize; + final startY = cardPosition.dy + cardSize.height / 2 + 20 - seedSize; + //end is top left if column mode (going towards vocab stats) or top right of card otherwise + final endX = (columnMode) ? 0.0 : cardPosition.dx + cardSize.width; + final endY = (columnMode) ? 0.0 : cardPosition.dy + 30; + final currentX = startX * (1 - t) + endX * t; + final currentY = startY * (1 - t) + endY * t; + //Grows into frame, and then shrinks if going to top right so it matches card seed size + + return Positioned( + left: currentX, + top: currentY, + child: Opacity( + opacity: fade, + child: Transform.rotate( + angle: scale * 2 * pi, + child: SizedBox( + //if going to card top right, shrinks as it moves to match word card seed size + width: seedSize, + height: seedSize, + child: svg ?? const SizedBox(), + ), + ), + ), + ); + }, + ), + ); + Overlay.of(context).insert(_overlayEntry!); + _controller?.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _overlayEntry?.remove(); + _overlayEntry = null; + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + height: cardSize.height, + width: cardSize.width, + color: Colors.transparent, + ), + Positioned( + left: 5, + right: 5, + top: 50, + bottom: 5, + child: Container( + height: cardSize.height, + width: cardSize.width, + color: widget.overlayColor, + ), + ), + ], + ); + } +} diff --git a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart index dcb86b6eb..2a0548beb 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; @@ -15,18 +15,21 @@ import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/new_word_overlay.dart'; import 'package:fluffychat/widgets/matrix.dart'; class WordZoomWidget extends StatelessWidget { final PangeaToken token; final PangeaMessageEvent messageEvent; final MessageOverlayController overlayController; + final bool wordIsNew; const WordZoomWidget({ super.key, required this.token, required this.messageEvent, required this.overlayController, + required this.wordIsNew, }); PangeaToken get _selectedToken => overlayController.selectedToken!; @@ -41,205 +44,223 @@ class WordZoomWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12.0), - constraints: const BoxConstraints( - minHeight: AppConfig.toolbarMinHeight - 8, - maxHeight: AppConfig.toolbarMaxHeight - 8, - maxWidth: AppConfig.toolbarMinWidth, - ), - child: SingleChildScrollView( - child: Column( - spacing: 12.0, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final GlobalKey cardKey = MatrixState.pAnyState + .layerLinkAndKey("word-zoom-card-${token.text.uniqueKey}") + .key; + final overlayColor = Theme.of(context).scaffoldBackgroundColor; + return Stack( + children: [ + Container( + key: cardKey, + padding: const EdgeInsets.all(12.0), + constraints: const BoxConstraints( + minHeight: AppConfig.toolbarMinHeight - 8, + maxHeight: AppConfig.toolbarMaxHeight - 8, + maxWidth: AppConfig.toolbarMinWidth, + ), + child: SingleChildScrollView( + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 24.0, - height: 24.0, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => overlayController.updateSelectedSpan( - token.text, - ), - child: const Icon( - Icons.close, - size: 16.0, - ), - ), - ), - ), - Flexible( - child: Text( - token.text.content, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 32.0, - fontWeight: FontWeight.w600, - height: 1.2, - color: Theme.of(context).brightness == Brightness.light - ? AppConfig.yellowDark - : AppConfig.yellowLight, - ), - ), - ), - ConstructXpWidget( - id: token.vocabConstructID, - onTap: () => showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - constructZoom: token.vocabConstructID, - view: ConstructTypeEnum.vocab, - ), - ), - ), - ], - ), - LemmaMeaningBuilder( - langCode: messageEvent.messageDisplayLangCode, - constructId: token.vocabConstructID, - builder: (context, controller) { - if (controller.editMode) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning( - token.vocabConstructID.lemma, - token.vocabConstructID.category, - )}", - textAlign: TextAlign.center, - style: const TextStyle(fontStyle: FontStyle.italic), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: TextField( - minLines: 1, - maxLines: 3, - controller: controller.controller, - decoration: InputDecoration( - hintText: controller.lemmaInfo?.meaning, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 24.0, + height: 24.0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => overlayController.updateSelectedSpan( + token.text, + ), + child: const Icon( + Icons.close, + size: 16.0, ), ), ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, + ), + Flexible( + child: Text( + token.text.content, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32.0, + fontWeight: FontWeight.w600, + height: 1.2, + color: + Theme.of(context).brightness == Brightness.light + ? AppConfig.yellowDark + : AppConfig.yellowLight, + ), + ), + ), + ConstructXpWidget( + id: token.vocabConstructID, + onTap: () => context.go( + "/rooms/analytics?mode=vocab", + extra: token.vocabConstructID, + ), + ), + ], + ), + LemmaMeaningBuilder( + langCode: messageEvent.messageDisplayLangCode, + constructId: token.vocabConstructID, + builder: (context, controller) { + if (controller.editMode) { + return Column( + mainAxisSize: MainAxisSize.min, children: [ - ElevatedButton( - onPressed: () => controller.toggleEditMode(false), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - padding: - const EdgeInsets.symmetric(horizontal: 10), - ), - child: Text(L10n.of(context).cancel), + Text( + "${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning( + token.vocabConstructID.lemma, + token.vocabConstructID.category, + )}", + textAlign: TextAlign.center, + style: const TextStyle(fontStyle: FontStyle.italic), ), - const SizedBox(width: 10), - ElevatedButton( - onPressed: () => controller.controller.text != - controller.lemmaInfo?.meaning && - controller.controller.text.isNotEmpty - ? controller.editLemmaMeaning( - controller.controller.text, - ) - : null, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), + const SizedBox(height: 10), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + minLines: 1, + maxLines: 3, + controller: controller.controller, + decoration: InputDecoration( + hintText: controller.lemmaInfo?.meaning, ), - padding: - const EdgeInsets.symmetric(horizontal: 10), ), - child: Text(L10n.of(context).saveChanges), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => + controller.toggleEditMode(false), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + ), + child: Text(L10n.of(context).cancel), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () => controller.controller.text != + controller.lemmaInfo?.meaning && + controller.controller.text.isNotEmpty + ? controller.editLemmaMeaning( + controller.controller.text, + ) + : null, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + ), + child: Text(L10n.of(context).saveChanges), + ), + ], ), ], - ), - ], - ); - } + ); + } - return Column( - spacing: 12.0, - mainAxisSize: MainAxisSize.min, - children: [ - if (MatrixState - .pangeaController.languageController.showTrancription) - PhoneticTranscriptionWidget( - text: token.text.content, - textLanguage: PLanguageStore.byLangCode( - messageEvent.messageDisplayLangCode, - ) ?? - LanguageModel.unknown, - style: const TextStyle(fontSize: 14.0), - iconSize: 24.0, - ) - else - WordAudioButton( - text: token.text.content, - uniqueID: "lemma-content-${token.text.content}", - langCode: messageEvent.messageDisplayLangCode, - iconSize: 24.0, - ), - LemmaReactionPicker( - cId: _selectedToken.vocabConstructID, - controller: overlayController.widget.chatController, - ), - if (controller.error != null) - Text( - L10n.of(context).oopsSomethingWentWrong, - textAlign: TextAlign.center, - ) - else if (controller.isLoading || - controller.lemmaInfo == null) - const CircularProgressIndicator.adaptive() - else - GestureDetector( - onLongPress: () => controller.toggleEditMode(true), - onDoubleTap: () => controller.toggleEditMode(true), - child: token.lemma.text == token.text.content - ? Text( - controller.lemmaInfo!.meaning, - style: const TextStyle(fontSize: 14.0), - textAlign: TextAlign.center, - ) - : RichText( - text: TextSpan( - style: DefaultTextStyle.of(context) - .style - .copyWith( - fontSize: 14.0, - ), - children: [ - TextSpan(text: token.lemma.text), - const WidgetSpan( - child: SizedBox(width: 8.0), + return Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + if (MatrixState.pangeaController.languageController + .showTrancription) + PhoneticTranscriptionWidget( + text: token.text.content, + textLanguage: PLanguageStore.byLangCode( + messageEvent.messageDisplayLangCode, + ) ?? + LanguageModel.unknown, + style: const TextStyle(fontSize: 14.0), + iconSize: 24.0, + ) + else + WordAudioButton( + text: token.text.content, + uniqueID: "lemma-content-${token.text.content}", + langCode: messageEvent.messageDisplayLangCode, + iconSize: 24.0, + ), + LemmaReactionPicker( + cId: _selectedToken.vocabConstructID, + controller: overlayController.widget.chatController, + ), + if (controller.error != null) + Text( + L10n.of(context).oopsSomethingWentWrong, + textAlign: TextAlign.center, + ) + else if (controller.isLoading || + controller.lemmaInfo == null) + const CircularProgressIndicator.adaptive() + else + GestureDetector( + onLongPress: () => controller.toggleEditMode(true), + onDoubleTap: () => controller.toggleEditMode(true), + child: token.lemma.text.toLowerCase() == + token.text.content.toLowerCase() + ? Text( + controller.lemmaInfo!.meaning, + style: const TextStyle(fontSize: 14.0), + textAlign: TextAlign.center, + ) + : RichText( + text: TextSpan( + style: DefaultTextStyle.of(context) + .style + .copyWith( + fontSize: 14.0, + ), + children: [ + TextSpan(text: token.lemma.text), + const WidgetSpan( + child: SizedBox(width: 8.0), + ), + const TextSpan(text: ":"), + const WidgetSpan( + child: SizedBox(width: 8.0), + ), + TextSpan( + text: controller.lemmaInfo!.meaning, + ), + ], ), - const TextSpan(text: ":"), - const WidgetSpan( - child: SizedBox(width: 8.0), - ), - TextSpan( - text: controller.lemmaInfo!.meaning, - ), - ], - ), - ), - ), - ], - ); - }, + ), + ), + ], + ); + }, + ), + ], ), - ], + ), ), - ), + wordIsNew + ? NewWordOverlay( + overlayColor: overlayColor, + cardKey: cardKey, + ) + : const SizedBox.shrink(), + ], ); } } diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 4787e4459..b6ff1a493 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -136,6 +136,7 @@ abstract class ClientManager { PangeaEventTypes.userSetLemmaInfo, EventTypes.RoomJoinRules, PangeaEventTypes.activityPlan, + PangeaEventTypes.constructSummary, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose, diff --git a/lib/widgets/navigation_rail.dart b/lib/widgets/navigation_rail.dart index ac14abdb9..7d8a93f39 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -42,7 +42,7 @@ class SpacesNavigationRail extends StatelessWidget { .startsWith('/rooms/settings'); // #Pangea final path = GoRouter.of(context).routeInformationProvider.value.uri.path; - final isHomepage = path.contains('homepage'); + final isAnalytics = path.contains('analytics'); final isCommunities = path.contains('communities'); final isColumnMode = FluffyThemes.isColumnMode(context); @@ -89,10 +89,10 @@ class SpacesNavigationRail extends StatelessWidget { // #Pangea if (i == 0) { return NaviRailItem( - isSelected: isHomepage, + isSelected: isAnalytics, onTap: () { clearActiveSpace?.call(); - context.go("/rooms/homepage"); + context.go("/rooms/analytics"); }, backgroundColor: Colors.transparent, icon: FutureBuilder( @@ -125,7 +125,7 @@ class SpacesNavigationRail extends StatelessWidget { // isSelected: activeSpaceId == null && !isSettings, isSelected: activeSpaceId == null && !isSettings && - !isHomepage && + !isAnalytics && !isCommunities, // Pangea# onTap: onGoToChats, diff --git a/pubspec.lock b/pubspec.lock index 3d8c649b8..fb5181fd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -38,6 +38,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.3.0" + animated_flip_counter: + dependency: "direct main" + description: + name: animated_flip_counter + sha256: "73f852d84c461c3e4c1ddf320bee334dde8dba89441922ab11a8013be0b2fad1" + url: "https://pub.dev" + source: hosted + version: "0.3.4" animations: dependency: "direct main" description: @@ -342,6 +350,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751" + url: "https://pub.dev" + source: hosted + version: "0.8.0" console: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1ecac15fb..ab650ec05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.10+2 +version: 4.1.12+1 environment: sdk: ">=3.0.0 <4.0.0" @@ -21,6 +21,7 @@ dependencies: chewie: ^1.11.3 collection: ^1.18.0 cross_file: ^0.3.4+2 + confetti: ^0.8.0 cupertino_icons: any # #Pangea # desktop_drop: ^0.4.4 @@ -135,6 +136,7 @@ dependencies: text_to_speech: git: https://github.com/pangeachat/text_to_speech.git flutter_tts: ^4.2.0 + animated_flip_counter: ^0.3.4 # Pangea# dev_dependencies: diff --git a/web/index.html b/web/index.html index d29793e01..5a5e6967d 100644 --- a/web/index.html +++ b/web/index.html @@ -69,6 +69,33 @@ }); }); + + + + +