From a17aede84e69604f574220c9850fefb08d027270 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 10:32:17 -0500 Subject: [PATCH 01/11] fix: always add padding around practice page content --- .../vocab_practice/vocab_practice_view.dart | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/vocab_practice/vocab_practice_view.dart index a196e4186..7fd3783e8 100644 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ b/lib/pangea/vocab_practice/vocab_practice_view.dart @@ -67,26 +67,28 @@ class VocabPracticeView extends StatelessWidget { ], ), ), - body: MaxWidthBody( - withScrolling: false, + body: Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 24.0, ), - showBorder: false, - child: ValueListenableBuilder( - valueListenable: controller.sessionState, - builder: (context, state, __) { - return switch (state) { - AsyncError(:final error) => - ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => - value.isComplete - ? CompletedActivitySessionView(state.value, controller) - : _VocabActivityView(controller), - _ => loading, - }; - }, + child: MaxWidthBody( + withScrolling: false, + showBorder: false, + child: ValueListenableBuilder( + valueListenable: controller.sessionState, + builder: (context, state, __) { + return switch (state) { + AsyncError(:final error) => + ErrorIndicator(message: error.toString()), + AsyncLoaded(:final value) => + value.isComplete + ? CompletedActivitySessionView(state.value, controller) + : _VocabActivityView(controller), + _ => loading, + }; + }, + ), ), ), ); From 0932d0c535ab6efcdd67553fb93296fe04c4403b Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 11:59:48 -0500 Subject: [PATCH 02/11] feat: backoff after failed igc and tokens requests in message sending flow --- .../choreographer/choreo_constants.dart | 1 + lib/pangea/choreographer/choreographer.dart | 43 +++++++++++++++++-- .../choreographer/igc/igc_controller.dart | 5 ++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/pangea/choreographer/choreo_constants.dart b/lib/pangea/choreographer/choreo_constants.dart index 85b9e6186..a303266ce 100644 --- a/lib/pangea/choreographer/choreo_constants.dart +++ b/lib/pangea/choreographer/choreo_constants.dart @@ -11,4 +11,5 @@ class ChoreoConstants { static const int msBeforeIGCStart = 10000; static const int maxLength = 1000; static const String inputTransformTargetKey = 'input_text_field'; + static const int defaultErrorBackoffSeconds = 5; } diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 52348dea6..a04c1e738 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:async/async.dart'; + import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreo_mode_enum.dart'; @@ -45,6 +47,11 @@ class Choreographer extends ChangeNotifier { String? _lastChecked; ChoreoModeEnum _choreoMode = ChoreoModeEnum.igc; + DateTime? _lastIgcError; + DateTime? _lastTokensError; + int _igcErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + int _tokenErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + StreamSubscription? _languageSub; StreamSubscription? _settingsUpdateSub; StreamSubscription? _acceptedContinuanceSub; @@ -68,6 +75,12 @@ class Choreographer extends ChangeNotifier { openMatches: [], ); + bool _backoffRequest(DateTime? error, int backoffSeconds) { + if (error == null) return false; + final secondsSinceError = DateTime.now().difference(error).inSeconds; + return secondsSinceError <= backoffSeconds; + } + void _initialize() { textController = PangeaTextController(choreographer: this); textController.addListener(_onChange); @@ -82,7 +95,14 @@ class Choreographer extends ChangeNotifier { itController.editing.addListener(_onSubmitSourceTextEdits); igcController = IgcController( - (e) => errorService.setErrorAndLock(ChoreoError(raw: e)), + (e) { + errorService.setErrorAndLock(ChoreoError(raw: e)); + _lastIgcError = DateTime.now(); + _igcErrorBackoff *= 2; + }, + () { + _igcErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + }, ); _languageSub ??= MatrixState @@ -233,7 +253,8 @@ class Choreographer extends ChangeNotifier { !ToolSetting.interactiveTranslator.enabled) || (!ToolSetting.autoIGC.enabled && !manual && - _choreoMode != ChoreoModeEnum.it)) { + _choreoMode != ChoreoModeEnum.it) || + _backoffRequest(_lastIgcError, _igcErrorBackoff)) { return; } @@ -275,7 +296,9 @@ class Choreographer extends ChangeNotifier { MatrixState.pangeaController.userController.userL2?.langCode; final l1LangCode = MatrixState.pangeaController.userController.userL1?.langCode; - if (l1LangCode != null && l2LangCode != null) { + if (l1LangCode != null && + l2LangCode != null && + !_backoffRequest(_lastTokensError, _tokenErrorBackoff)) { final res = await TokensRepo.get( MatrixState.pangeaController.userController.accessToken, TokensRequestModel( @@ -283,7 +306,21 @@ class Choreographer extends ChangeNotifier { senderL1: l1LangCode, senderL2: l2LangCode, ), + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + return Result.error("Token request timed out"); + }, ); + + if (res.isError) { + _lastTokensError = DateTime.now(); + _tokenErrorBackoff *= 2; + } else { + // reset backoff on success + _tokenErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + } + tokensResp = res.isValue ? res.result : null; } diff --git a/lib/pangea/choreographer/igc/igc_controller.dart b/lib/pangea/choreographer/igc/igc_controller.dart index afe661ff9..9b78cb439 100644 --- a/lib/pangea/choreographer/igc/igc_controller.dart +++ b/lib/pangea/choreographer/igc/igc_controller.dart @@ -18,7 +18,8 @@ import 'package:fluffychat/widgets/matrix.dart'; class IgcController { final Function(Object) onError; - IgcController(this.onError); + final VoidCallback onFetch; + IgcController(this.onError, this.onFetch); bool _isFetching = false; String? _currentText; @@ -321,6 +322,8 @@ class IgcController { onError(res.asError!); clear(); return; + } else { + onFetch(); } if (!_isFetching) return; From 73f19471897bd66536731ffb0888108bf6de9266 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 12:07:03 -0500 Subject: [PATCH 03/11] fix: only show you in left chat message is user is the current logged in user --- lib/pages/chat/events/state_message.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pages/chat/events/state_message.dart b/lib/pages/chat/events/state_message.dart index d55f286b1..2975c8a06 100644 --- a/lib/pages/chat/events/state_message.dart +++ b/lib/pages/chat/events/state_message.dart @@ -29,8 +29,10 @@ class StateMessage extends StatelessWidget { // event.calcLocalizedBodyFallback( // MatrixLocals(L10n.of(context)), // ), - event.type == EventTypes.RoomMember && - event.roomMemberChangeType == RoomMemberChangeType.leave + (event.type == EventTypes.RoomMember) && + (event.roomMemberChangeType == + RoomMemberChangeType.leave) && + (event.stateKey == event.room.client.userID) ? L10n.of(context).youLeftTheChat : event.calcLocalizedBodyFallback( MatrixLocals(L10n.of(context)), From 7985214670acef8721b62610f9e437ff7b37d667 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 12:20:19 -0500 Subject: [PATCH 04/11] chore: reset font size on logout --- lib/pangea/common/controllers/pangea_controller.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 3d80b2204..b1339a3e4 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -122,7 +122,9 @@ class PangeaController { } Future _clearCache({List exclude = const []}) async { - final List> futures = []; + final List> futures = [ + matrixState.store.setString(SettingKeys.fontSizeFactor, ''), + ]; for (final key in _storageKeys) { if (exclude.contains(key)) continue; futures.add(GetStorage(key).erase()); @@ -140,6 +142,7 @@ class PangeaController { ); } + AppConfig.fontSizeFactor = 1.0; await Future.wait(futures); } From f3b2feac20303c103f9128ddc5aa6e4f700ad3c7 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:39:04 -0500 Subject: [PATCH 05/11] fix: show alt text after flipping practice choice --- lib/pangea/vocab_practice/choice_cards/game_choice_card.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart index c4bed1767..ad2e5c461 100644 --- a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart +++ b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart @@ -100,7 +100,6 @@ class _GameChoiceCardState extends State animation: _scaleAnim, builder: (context, _) { final scale = _scaleAnim.value; - final showAlt = scale < 0.1 && widget.altChild != null; final showContent = scale > 0.05; return Transform.scale( @@ -113,7 +112,7 @@ class _GameChoiceCardState extends State : (hovered ? hoverColor : Colors.transparent), child: Opacity( opacity: showContent ? 1 : 0, - child: showAlt ? widget.altChild! : widget.child, + child: _revealed ? widget.altChild! : widget.child, ), ), ); From bc66198bf45675c68d1855b671a3032821d7e58d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 12:41:06 -0500 Subject: [PATCH 06/11] chore: reset download state on switch download type --- .../analytics_downloads/analytics_dowload_dialog.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart index fe0af6e37..ea0739580 100644 --- a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart +++ b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart @@ -50,7 +50,13 @@ class AnalyticsDownloadDialogState extends State { } void _setDownloadType(DownloadType type) { - if (mounted) setState(() => _downloadType = type); + if (mounted) { + setState(() { + _downloadType = type; + _downloaded = false; + _error = null; + }); + } } Future _downloadAnalytics() async { @@ -427,7 +433,8 @@ class AnalyticsDownloadDialogState extends State { padding: const EdgeInsets.all(8.0), child: SegmentedButton( selected: {_downloadType}, - onSelectionChanged: (c) => _setDownloadType(c.first), + onSelectionChanged: + _downloading ? null : (c) => _setDownloadType(c.first), segments: [ ButtonSegment( value: DownloadType.csv, From 971fc5508e89bfd6689908a2728d84fa7b9bd34c Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 12:51:26 -0500 Subject: [PATCH 07/11] fix: account for left rooms in join public course flow --- lib/pangea/course_creation/selected_course_page.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pangea/course_creation/selected_course_page.dart b/lib/pangea/course_creation/selected_course_page.dart index 1b8b3848e..299c784f4 100644 --- a/lib/pangea/course_creation/selected_course_page.dart +++ b/lib/pangea/course_creation/selected_course_page.dart @@ -169,12 +169,12 @@ class SelectedCourseController extends State : await client.joinRoom(widget.roomChunk!.roomId); Room? room = client.getRoomById(roomId); - if (!knock && room == null) { - await client.waitForRoomInSync(roomId); + if (!knock && room?.membership != Membership.join) { + await client.waitForRoomInSync(roomId, join: true); room = client.getRoomById(roomId); } - if (knock && room == null) { + if (knock) { Navigator.of(context).pop(); await showOkAlertDialog( context: context, From dac3c5edf54be11fd71f9cfdd066dc75be394120 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 13:00:19 -0500 Subject: [PATCH 08/11] chore: show course info chips in one column mode --- lib/pangea/chat_settings/pages/space_details_content.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pangea/chat_settings/pages/space_details_content.dart b/lib/pangea/chat_settings/pages/space_details_content.dart index 6a4db73b4..b57233edd 100644 --- a/lib/pangea/chat_settings/pages/space_details_content.dart +++ b/lib/pangea/chat_settings/pages/space_details_content.dart @@ -296,7 +296,7 @@ class SpaceDetailsContent extends StatelessWidget { ], Flexible( child: Column( - spacing: 12.0, + spacing: isColumnMode ? 12.0 : 6.0, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -311,7 +311,7 @@ class SpaceDetailsContent extends StatelessWidget { : FontWeight.bold, ), ), - if (isColumnMode && room.coursePlan != null) + if (room.coursePlan != null) CourseInfoChips( room.coursePlan!.uuid, fontSize: 12.0, From a25bf26779af4e57e87dac9890de7ad102db4455 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 13:18:26 -0500 Subject: [PATCH 09/11] fix: use room state stream to drive updates to analytics request indicator --- .../analytics_request_indicator.dart | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/lib/pangea/space_analytics/analytics_request_indicator.dart b/lib/pangea/space_analytics/analytics_request_indicator.dart index 180a0bc6f..9ba4e910b 100644 --- a/lib/pangea/space_analytics/analytics_request_indicator.dart +++ b/lib/pangea/space_analytics/analytics_request_indicator.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics_requested_dialog.dart'; +import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; class AnalyticsRequestIndicator extends StatefulWidget { @@ -26,51 +27,68 @@ class AnalyticsRequestIndicator extends StatefulWidget { class AnalyticsRequestIndicatorState extends State { AnalyticsRequestIndicatorState(); - - final Map> _knockingAdmins = {}; + StreamSubscription? _analyticsRoomSub; @override void initState() { super.initState(); - _fetchKnockingAdmins(); + _init(); } @override void didUpdateWidget(covariant AnalyticsRequestIndicator oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.room.id != widget.room.id) { - _fetchKnockingAdmins(); + _init(); } } - Future _fetchKnockingAdmins() async { - setState(() => _knockingAdmins.clear()); + @override + void dispose() { + _analyticsRoomSub?.cancel(); + super.dispose(); + } - final admins = (await widget.room.requestParticipants( - [Membership.join, Membership.invite, Membership.knock], - false, - true, - )) - .where((u) => u.powerLevel >= 100); + Future _init() async { + final analyticsRooms = widget.room.client.allMyAnalyticsRooms; + final futures = analyticsRooms.map( + (r) => r.requestParticipants( + [Membership.join, Membership.invite, Membership.knock], + false, + true, + ), + ); + await Future.wait(futures); + final analyicsRoomIds = analyticsRooms.map((r) => r.id).toSet(); + _analyticsRoomSub?.cancel(); + _analyticsRoomSub = widget.room.client.onRoomState.stream + .where( + (event) => + analyicsRoomIds.contains(event.roomId) && + event.state.type == EventTypes.RoomMember, + ) + .rateLimit(const Duration(seconds: 1)) + .listen((_) => setState(() {})); + + if (mounted) setState(() {}); + } + + Map> get _knockingAdmins { + final Map> knockingAdmins = {}; for (final analyticsRoom in widget.room.client.allMyAnalyticsRooms) { - final knocking = await analyticsRoom.requestParticipants( - [Membership.knock], - ); - final knockingSpace = - knocking.where((u) => u.content['reason'] == widget.room.id).toList(); - if (knockingSpace.isEmpty) continue; + final knocking = analyticsRoom + .getParticipants([Membership.knock]) + .where((u) => u.content['reason'] == widget.room.id) + .toList(); - for (final admin in admins) { - if (knockingSpace.any((u) => u.id == admin.id)) { - _knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom); - } + if (knocking.isEmpty) continue; + for (final admin in knocking) { + knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom); } } - if (mounted) { - setState(() {}); - } + return knockingAdmins; } Future _onTap(BuildContext context) async { @@ -109,8 +127,6 @@ class AnalyticsRequestIndicatorState extends State { } }, ); - - if (mounted) _fetchKnockingAdmins(); } @override From 95321c2e5a801b89d03f2bc6775a637e31575196 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 13:34:02 -0500 Subject: [PATCH 10/11] feat: add transcript in vocab practice view --- .../vocab_practice/vocab_practice_page.dart | 20 +++++++++++++-- .../vocab_practice/vocab_practice_view.dart | 25 +++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/vocab_practice/vocab_practice_page.dart index 4bfbfc111..d03153161 100644 --- a/lib/pangea/vocab_practice/vocab_practice_page.dart +++ b/lib/pangea/vocab_practice/vocab_practice_page.dart @@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_m import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart'; @@ -258,6 +259,12 @@ class VocabPracticeState extends State with AnalyticsUpdater { activityState.value = const AsyncState.loading(); final nextActivityCompleter = _queue.removeFirst(); activityConstructId.value = nextActivityCompleter.key; + TtsController.tryToSpeak( + nextActivityCompleter.key.lemma, + langCode: + MatrixState.pangeaController.userController.userL2!.langCode, + ); + final activity = await nextActivityCompleter.value.future; activityState.value = AsyncState.loaded(activity); } @@ -276,12 +283,16 @@ class VocabPracticeState extends State with AnalyticsUpdater { try { activityState.value = const AsyncState.loading(); - final req = requests.first; + activityConstructId.value = req.targetTokens.first.vocabConstructID; + TtsController.tryToSpeak( + req.targetTokens.first.vocabConstructID.lemma, + langCode: MatrixState.pangeaController.userController.userL2!.langCode, + ); + final res = await _fetchActivity(req); if (!mounted) return; - activityConstructId.value = req.targetTokens.first.vocabConstructID; activityState.value = AsyncState.loaded(res); } catch (e) { if (!mounted) return; @@ -397,6 +408,11 @@ class VocabPracticeState extends State with AnalyticsUpdater { await _saveSession(); if (!correct) return; + TtsController.tryToSpeak( + activity.targetTokens.first.vocabConstructID.lemma, + langCode: MatrixState.pangeaController.userController.userL2!.langCode, + ); + // Display the fact that the choice was correct before loading the next activity await Future.delayed(const Duration(milliseconds: 1000)); diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/vocab_practice/vocab_practice_view.dart index 7fd3783e8..d4805c373 100644 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ b/lib/pangea/vocab_practice/vocab_practice_view.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; +import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.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/vocab_practice/choice_cards/audio_choice_card.dart'; @@ -17,6 +18,7 @@ import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class VocabPracticeView extends StatelessWidget { final VocabPracticeState controller; @@ -123,13 +125,26 @@ class _VocabActivityView extends StatelessWidget { child: ValueListenableBuilder( valueListenable: controller.activityConstructId, builder: (context, constructId, __) => constructId != null - ? Text( - constructId.lemma, - textAlign: TextAlign.center, - style: - Theme.of(context).textTheme.titleLarge?.copyWith( + ? Column( + spacing: 12.0, + children: [ + Text( + constructId.lemma, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( fontWeight: FontWeight.bold, ), + ), + PhoneticTranscriptionWidget( + text: constructId.lemma, + textLanguage: MatrixState + .pangeaController.userController.userL2!, + style: const TextStyle(fontSize: 14.0), + ), + ], ) : const SizedBox(), ), From 08680b56f8bb01a3a688deb779080ca4677897af Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 16 Jan 2026 13:54:08 -0500 Subject: [PATCH 11/11] chore: add 2 second delay on hover before expanding navigation menu --- .../spaces/space_navigation_column.dart | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/lib/pangea/spaces/space_navigation_column.dart b/lib/pangea/spaces/space_navigation_column.dart index cc6891cdf..3c04607d7 100644 --- a/lib/pangea/spaces/space_navigation_column.dart +++ b/lib/pangea/spaces/space_navigation_column.dart @@ -31,40 +31,38 @@ class SpaceNavigationColumn extends StatefulWidget { } class SpaceNavigationColumnState extends State { + bool _hovered = false; bool _expanded = false; - Timer? _debounceTimer; + Timer? _timer; + + void _onHoverUpdate(bool hovered) { + if (hovered == _hovered) return; + _hovered = hovered; + _cancelTimer(); + + if (hovered) { + _timer = Timer(const Duration(milliseconds: 200), () { + if (_hovered && mounted) { + setState(() => _expanded = true); + } + _cancelTimer(); + }); + } else { + setState(() => _expanded = false); + } + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } @override void dispose() { - _debounceTimer?.cancel(); - _debounceTimer = null; + _cancelTimer(); super.dispose(); } - void _expand() { - if (_debounceTimer?.isActive == true) return; - if (!_expanded) { - setState(() => _expanded = true); - } - } - - void _collapse() { - if (_expanded) { - setState(() { - _expanded = false; - _debounce(); - }); - } - } - - void _debounce() { - _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - _debounceTimer?.cancel(); - _debounceTimer = null; - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -115,7 +113,7 @@ class SpaceNavigationColumnState extends State { HoverBuilder( builder: (context, hovered) { WidgetsBinding.instance.addPostFrameCallback((_) { - hovered ? _expand() : _collapse(); + _onHoverUpdate(hovered); }); return Row( @@ -128,7 +126,10 @@ class SpaceNavigationColumnState extends State { ? navRailWidth + navRailExtraWidth : navRailWidth, expanded: _expanded, - collapse: _collapse, + collapse: () { + _cancelTimer(); + setState(() => _expanded = false); + }, ), Container( width: 1,