diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 50af5f3a5..1c10b1208 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -85,6 +85,7 @@ class AnalyticsDataService { void dispose() { _syncController?.dispose(); updateDispatcher.dispose(); + updateService.dispose(); _closeDatabase(); } diff --git a/lib/pangea/analytics_data/analytics_sync_controller.dart b/lib/pangea/analytics_data/analytics_sync_controller.dart index 53ccd760a..f895c5657 100644 --- a/lib/pangea/analytics_data/analytics_sync_controller.dart +++ b/lib/pangea/analytics_data/analytics_sync_controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; @@ -52,6 +53,13 @@ class AnalyticsSyncController { if (constructEvents.isEmpty) return; await dataService.updateServerAnalytics(constructEvents); + + // Server updates do not usually need to update the UI, since usually they are only + // transfering local data to the server. However, if a user if using multiple devices, + // we do need to update the UI when new data comes from the server. + dataService.updateDispatcher.sendConstructAnalyticsUpdate( + AnalyticsUpdate([]), + ); } Future waitForSync(String analyticsRoomId) async { diff --git a/lib/pangea/analytics_data/analytics_update_service.dart b/lib/pangea/analytics_data/analytics_update_service.dart index cdb832650..d2bfd164e 100644 --- a/lib/pangea/analytics_data/analytics_update_service.dart +++ b/lib/pangea/analytics_data/analytics_update_service.dart @@ -23,9 +23,19 @@ class AnalyticsUpdateService { final AnalyticsDataService dataService; - AnalyticsUpdateService(this.dataService); + AnalyticsUpdateService(this.dataService) { + _periodicTimer = Timer.periodic( + const Duration(minutes: 5), + (_) => sendLocalAnalyticsToAnalyticsRoom(), + ); + } Completer? _updateCompleter; + Timer? _periodicTimer; + + void dispose() { + _periodicTimer?.cancel(); + } LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; @@ -50,8 +60,9 @@ class AnalyticsUpdateService { Future addAnalytics( String? targetID, - List newConstructs, - ) async { + List newConstructs, { + bool forceUpdate = false, + }) async { await dataService.updateDispatcher.sendConstructAnalyticsUpdate( AnalyticsUpdate( newConstructs, @@ -63,7 +74,9 @@ class AnalyticsUpdateService { final lastUpdated = await dataService.getLastUpdatedAnalytics(); final difference = DateTime.now().difference(lastUpdated ?? DateTime.now()); - if (localConstructCount > _maxMessagesCached || difference.inMinutes > 10) { + if (forceUpdate || + localConstructCount > _maxMessagesCached || + difference.inMinutes > 10) { sendLocalAnalyticsToAnalyticsRoom(); } } diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index 4a9f32681..3b60338c8 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:diacritic/diacritic.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/l10n/l10n.dart'; @@ -106,7 +107,11 @@ class ConstructAnalyticsViewState extends State { vocab = data.values.toList(); vocab!.sort( - (a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase()), + (a, b) { + final normalizedA = removeDiacritics(a.lemma).toLowerCase(); + final normalizedB = removeDiacritics(b.lemma).toLowerCase(); + return normalizedA.compareTo(normalizedB); + }, ); } finally { if (mounted) setState(() {}); diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart index 564069291..91f81c172 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart @@ -77,7 +77,7 @@ class VocabAnalyticsListTile extends StatelessWidget { }, ), Container( - alignment: Alignment.topCenter, + alignment: Alignment.center, padding: const EdgeInsets.only(top: 4), height: (maxWidth - padding * 2) * 0.4, child: ShrinkableText( diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index e9d83baca..c46151ce3 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -253,7 +253,11 @@ class AnalyticsPracticeState extends State setState(() {}); final bonus = _sessionLoader.value!.state.allBonusUses; - await _analyticsService.updateService.addAnalytics(null, bonus); + await _analyticsService.updateService.addAnalytics( + null, + bonus, + forceUpdate: true, + ); await _saveSession(); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 27c4d5700..134b96111 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -91,11 +91,17 @@ class AnalyticsPracticeSessionRepo { return dateA.compareTo(dateB); }); - return constructs - .where((construct) => construct.lemma.isNotEmpty) - .take(AnalyticsPracticeConstants.practiceGroupSize) - .map((construct) => construct.id) - .toList(); + final Set seemLemmas = {}; + final targets = []; + for (final construct in constructs) { + if (seemLemmas.contains(construct.lemma)) continue; + seemLemmas.add(construct.lemma); + targets.add(construct.id); + if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + break; + } + } + return targets; } static Future> _fetchMorphs() async { diff --git a/lib/pangea/course_creation/selected_course_page.dart b/lib/pangea/course_creation/selected_course_page.dart index f43fa3401..1b8b3848e 100644 --- a/lib/pangea/course_creation/selected_course_page.dart +++ b/lib/pangea/course_creation/selected_course_page.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/spaces/client_spaces_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; enum SelectedCourseMode { launch, addToSpace, join } @@ -74,7 +75,9 @@ class SelectedCourseController extends State case SelectedCourseMode.addToSpace: return L10n.of(context).addCoursePlan; case SelectedCourseMode.join: - return L10n.of(context).joinWithClassCode; + return widget.roomChunk?.joinRule == JoinRules.knock.name + ? L10n.of(context).knock + : L10n.of(context).join; } } @@ -152,16 +155,36 @@ class SelectedCourseController extends State } final client = Matrix.of(context).client; - final roomId = await client.joinRoom( - widget.roomChunk!.roomId, - ); - - final room = client.getRoomById(roomId); - if (room == null || room.membership != Membership.join) { - await client.waitForRoomInSync(roomId, join: true); + final r = client.getRoomById(widget.roomChunk!.roomId); + if (r != null && r.membership == Membership.join) { + if (mounted) { + context.go("/rooms/spaces/${r.id}/details"); + } + return; } - if (client.getRoomById(roomId) == null) { + final knock = widget.roomChunk!.joinRule == JoinRules.knock.name; + final roomId = widget.roomChunk != null && knock + ? await client.knockRoom(widget.roomChunk!.roomId) + : await client.joinRoom(widget.roomChunk!.roomId); + + Room? room = client.getRoomById(roomId); + if (!knock && room == null) { + await client.waitForRoomInSync(roomId); + room = client.getRoomById(roomId); + } + + if (knock && room == null) { + Navigator.of(context).pop(); + await showOkAlertDialog( + context: context, + title: L10n.of(context).youHaveKnocked, + message: L10n.of(context).pleaseWaitUntilInvited, + ); + return; + } + + if (room == null) { throw Exception("Failed to join room"); } diff --git a/lib/pangea/course_creation/selected_course_view.dart b/lib/pangea/course_creation/selected_course_view.dart index 463ceb453..6f54c0363 100644 --- a/lib/pangea/course_creation/selected_course_view.dart +++ b/lib/pangea/course_creation/selected_course_view.dart @@ -283,7 +283,8 @@ class SelectedCourseView extends StatelessWidget { ), child: Row( spacing: 8.0, - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, children: [ const Icon(Icons.map_outlined), Text( diff --git a/lib/pangea/login/pages/public_courses_page.dart b/lib/pangea/login/pages/public_courses_page.dart index db782fcf4..41a10a86a 100644 --- a/lib/pangea/login/pages/public_courses_page.dart +++ b/lib/pangea/login/pages/public_courses_page.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_req import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/spaces/public_course_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/matrix.dart'; class PublicCoursesPage extends StatefulWidget { @@ -52,10 +53,10 @@ class PublicCoursesPageState extends State { _loadCourses(); } - void setTargetLanguageFilter(LanguageModel? language, {bool reload = true}) { + void setTargetLanguageFilter(LanguageModel? language) { if (targetLanguageFilter?.langCodeShort == language?.langCodeShort) return; setState(() => targetLanguageFilter = language); - if (reload) _loadCourses(); + _loadCourses(); } List get filteredCourses { @@ -67,8 +68,7 @@ class PublicCoursesPageState extends State { r.id == c.room.roomId && r.membership == Membership.join, ) && - coursePlans.containsKey(c.courseId) && - c.room.joinRule == 'public', + coursePlans.containsKey(c.courseId), ) .toList(); @@ -83,16 +83,20 @@ class PublicCoursesPageState extends State { ).toList(); } + // sort by join rule, with knock rooms at the end + filtered.sort((a, b) { + final aKnock = a.room.joinRule == JoinRules.knock.name; + final bKnock = b.room.joinRule == JoinRules.knock.name; + if (aKnock && !bKnock) return 1; + if (!aKnock && bKnock) return -1; + return 0; + }); + return filtered; } - Future _loadCourses() async { + Future _loadPublicSpaces() async { try { - setState(() { - loading = true; - error = null; - }); - final resp = await Matrix.of(context).client.requestPublicCourses( since: nextBatch, ); @@ -114,6 +118,21 @@ class PublicCoursesPageState extends State { }, ); } + } + + Future _loadCourses() async { + setState(() { + loading = true; + error = null; + }); + + await _loadPublicSpaces(); + + int timesLoaded = 0; + while (error == null && timesLoaded < 5 && nextBatch != null) { + await _loadPublicSpaces(); + timesLoaded++; + } try { final resp = await CoursePlansRepo.search( @@ -333,6 +352,39 @@ class PublicCoursesPageState extends State { style: theme.textTheme.bodyMedium, ), ], + const SizedBox(height: 12.0), + HoverBuilder( + builder: (context, hovered) => + ElevatedButton( + onPressed: () => context.go( + '/${widget.route}/course/public/$courseId', + extra: roomChunk, + ), + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer + .withAlpha(hovered ? 255 : 200), + foregroundColor: theme + .colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12.0), + ), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Text( + roomChunk.joinRule == + JoinRules.knock.name + ? L10n.of(context).knock + : L10n.of(context).join, + ), + ], + ), + ), + ), ], ), ),