diff --git a/lib/app/layouts/conversation_list/pages/cupertino_conversation_list.dart b/lib/app/layouts/conversation_list/pages/cupertino_conversation_list.dart index 5c5fc00e10..e68cf860ea 100644 --- a/lib/app/layouts/conversation_list/pages/cupertino_conversation_list.dart +++ b/lib/app/layouts/conversation_list/pages/cupertino_conversation_list.dart @@ -363,6 +363,12 @@ class CupertinoConversationListState ), ); }), + // targetSdk 36 means Android always draws this behind the navigation bar + // and ignores systemNavigationBarColor, and a CustomScrollView (unlike the + // ListView the Material list uses) does not consume MediaQuery padding on + // its own, so the last chat sat under the gesture pill and could not be + // scrolled clear of it. The Samsung list already gets this from a SafeArea. + SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)), ], )), ), diff --git a/lib/app/wrappers/tablet_mode_wrapper.dart b/lib/app/wrappers/tablet_mode_wrapper.dart index ba6caed091..7c60a3585c 100644 --- a/lib/app/wrappers/tablet_mode_wrapper.dart +++ b/lib/app/wrappers/tablet_mode_wrapper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/helpers/ui/theme_helpers.dart'; @@ -43,6 +44,8 @@ class _TabletModeWrapperState extends OptimizedState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; + StreamSubscription? _eventSub; + Worker? _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -51,22 +54,40 @@ class _TabletModeWrapperState extends OptimizedState { @override void initState() { super.initState(); - _ratio = RxDouble((ss.prefs.getDouble('splitRatio') ?? widget.initialRatio).clamp(widget.minRatio, widget.maxRatio)); - eventDispatcher.stream.listen((event) { + _ratio = RxDouble(_clampRatio(ss.prefs.getDouble('splitRatio') ?? widget.initialRatio)); + _eventSub = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'split-refresh') { - _ratio.value = ss.prefs.getDouble('splitRatio') ?? _ratio.value; + // splitRatio is a single global pref shared by every TabletModeWrapper, and the search + // view drives a deliberately narrow ratio through it. Re-clamp on read so a value that + // was saved by another pane, or for a different screen size, cannot land outside the + // bounds this wrapper was built with. Only the constructor clamped before. + _ratio.value = _clampRatio(ss.prefs.getDouble('splitRatio') ?? _ratio.value); setState(() {}); } else if (event.item1 == 'override-split') { + // deliberately not clamped: goToSearch pushes this below minRatio and restores it after _ratio.value = event.item2; setState(() {}); } }); - debounce(_ratio, (val) async { + _ratioWorker = debounce(_ratio, (val) async { await ss.prefs.setDouble('splitRatio', val); eventDispatcher.emit('split-refresh', null); }); } + double _clampRatio(double value) => value.clamp(widget.minRatio, widget.maxRatio); + + @override + void dispose() { + // Neither of these was ever torn down. Every time this wrapper was rebuilt (skin change, + // theme switch, moving in and out of the split layout) it left another live listener and + // another debounce worker writing to the split pref. + _eventSub?.cancel(); + _ratioWorker?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!showAltLayout) { diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..d957765c26 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -148,6 +148,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker } StreamSubscription? shareSubscription; + StreamSubscription? keyboardSubscription; @override void onInit() { @@ -158,7 +159,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker updateContactInfo(); textController.mentionables = mentionables; - KeyboardVisibilityController().onChange.listen((bool visible) async { + keyboardSubscription = KeyboardVisibilityController().onChange.listen((bool visible) async { keyboardOpen = visible; if (scrollController.hasClients) { _keyboardOffset = scrollController.offset; @@ -229,6 +230,10 @@ class ConversationViewController extends StatefulController with GetSingleTicker scrollController.dispose(); headerBackFocusNode.dispose(); shareSubscription?.cancel(); + // This one was never cancelled, so every chat that was opened left a listener running for the + // rest of the process, each of them reaching into a scroll controller disposed on the line + // above every time the keyboard opened or closed. + keyboardSubscription?.cancel(); super.onClose(); } diff --git a/lib/services/ui/navigator/navigator_service.dart b/lib/services/ui/navigator/navigator_service.dart index 3840a014e4..0ef104e3d0 100644 --- a/lib/services/ui/navigator/navigator_service.dart +++ b/lib/services/ui/navigator/navigator_service.dart @@ -28,8 +28,12 @@ class NavigatorService extends GetxService { /// Returns widthChatListLeft if in tablet mode, and 0 otherwise double widthChatListLeft(BuildContext context) => isTabletMode(context) ? _widthChatListLeft ?? 0 : 0; + /// Must agree with [ThemeHelpers.showAltLayout], which is what actually decides whether the + /// nested navigators (ids 1 and 2) get built. Without the isBubble check an Android chat bubble + /// wide enough to clear 600dp reported tablet mode while the panes were never created, so + /// push/pushLeft/backConversationView routed into navigators that are not in the tree. bool isTabletMode(BuildContext context) => (!context.isPhone || context.width / context.height > 0.8) && - ss.settings.tabletMode.value && context.width > 600; + ss.settings.tabletMode.value && context.width > 600 && !ls.isBubble; /// grab the available screen width, returning the split screen width if applicable /// this should *always* be used in place of context.width or similar