From 90877a18911bf2f1a05c249220b18e942a01dbf6 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 13 Mar 2026 10:31:37 +0100 Subject: [PATCH] Add bidirectional refresh physics --- lib/widgets/common/bidirectional_refresh.dart | 176 ++++++++++++++++++ lib/widgets/contacts/contact_tile.dart | 12 -- lib/widgets/messages/messages_content.dart | 10 +- test/widgets/bidirectional_refresh_test.dart | 90 +++++++++ test/widgets/contact_tile_test.dart | 9 + 5 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 lib/widgets/common/bidirectional_refresh.dart create mode 100644 test/widgets/bidirectional_refresh_test.dart diff --git a/lib/widgets/common/bidirectional_refresh.dart b/lib/widgets/common/bidirectional_refresh.dart new file mode 100644 index 0000000..ca5601a --- /dev/null +++ b/lib/widgets/common/bidirectional_refresh.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +enum _RefreshEdge { top, bottom } + +class BidirectionalRefresh extends StatefulWidget { + static const double defaultTriggerDistance = 90; + + final Widget child; + final Future Function() onRefresh; + final double triggerDistance; + + const BidirectionalRefresh({ + super.key, + required this.child, + required this.onRefresh, + this.triggerDistance = defaultTriggerDistance, + }); + + @override + State createState() => _BidirectionalRefreshState(); +} + +class _BidirectionalRefreshState extends State { + double _topPullDistance = 0; + double _bottomPullDistance = 0; + bool _isRefreshing = false; + + bool _handleScrollNotification(ScrollNotification notification) { + if (_isRefreshing) { + return false; + } + + if (notification is ScrollStartNotification) { + _resetPullDistance(); + return false; + } + + if (notification is OverscrollNotification) { + final metrics = notification.metrics; + final isAtTop = metrics.extentBefore == 0; + final isAtBottom = metrics.extentAfter == 0; + + if (!isAtTop && !isAtBottom) { + return false; + } + + var shouldRefresh = false; + setState(() { + if (isAtTop) { + _topPullDistance += notification.overscroll.abs(); + shouldRefresh = _topPullDistance >= widget.triggerDistance; + } + if (isAtBottom) { + _bottomPullDistance += notification.overscroll.abs(); + shouldRefresh = + shouldRefresh || _bottomPullDistance >= widget.triggerDistance; + } + }); + if (shouldRefresh) { + _runRefresh(); + } + return false; + } + + if (notification is ScrollEndNotification) { + _resetPullDistance(); + } + + return false; + } + + Future _runRefresh() async { + if (_isRefreshing) { + return; + } + + setState(() { + _isRefreshing = true; + }); + + try { + await widget.onRefresh(); + } finally { + if (!mounted) { + return; + } + setState(() { + _isRefreshing = false; + _topPullDistance = 0; + _bottomPullDistance = 0; + }); + } + } + + void _resetPullDistance() { + if (_topPullDistance == 0 && _bottomPullDistance == 0) { + return; + } + + setState(() { + _topPullDistance = 0; + _bottomPullDistance = 0; + }); + } + + @override + Widget build(BuildContext context) { + final topProgress = (_topPullDistance / widget.triggerDistance).clamp( + 0.0, + 1.0, + ); + final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp( + 0.0, + 1.0, + ); + + return NotificationListener( + onNotification: _handleScrollNotification, + child: Stack( + children: [ + widget.child, + _RefreshIndicatorOverlay( + alignment: Alignment.topCenter, + progress: _isRefreshing ? 1 : topProgress, + visible: _isRefreshing || _topPullDistance > 0, + edge: _RefreshEdge.top, + ), + _RefreshIndicatorOverlay( + alignment: Alignment.bottomCenter, + progress: _isRefreshing ? 1 : bottomProgress, + visible: _isRefreshing || _bottomPullDistance > 0, + edge: _RefreshEdge.bottom, + ), + ], + ), + ); + } +} + +class _RefreshIndicatorOverlay extends StatelessWidget { + final Alignment alignment; + final double progress; + final bool visible; + final _RefreshEdge edge; + + const _RefreshIndicatorOverlay({ + required this.alignment, + required this.progress, + required this.visible, + required this.edge, + }); + + @override + Widget build(BuildContext context) { + if (!visible) { + return const SizedBox.shrink(); + } + + final indicator = RefreshProgressIndicator(value: progress); + final padding = edge == _RefreshEdge.top + ? const EdgeInsets.only(top: 12) + : const EdgeInsets.only(bottom: 12); + + return Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: alignment, + child: Padding( + padding: padding, + child: SizedBox.square(dimension: 28, child: indicator), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index aafd14a..0dc8d1b 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -145,12 +145,6 @@ class ContactTile extends StatelessWidget { icon: Icons.folder_copy_outlined, label: label, ), - _buildMetaPill( - context, - icon: Icons.key_outlined, - label: contact.publicKeyShort, - monospace: true, - ), ], ), if (location != null) ...[ @@ -303,12 +297,6 @@ class ContactTile extends StatelessWidget { final compactPills = [ if (groupLabel case final label?) _buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label), - _buildMetaPill( - context, - icon: Icons.key_outlined, - label: contact.publicKeyShort, - monospace: true, - ), if (distanceText != null) _buildDistancePill(context, distanceText), if (contact.routeHasPath && contact.routeHopCount > 0) _buildRoutePill(context, contact), diff --git a/lib/widgets/messages/messages_content.dart b/lib/widgets/messages/messages_content.dart index c7e094a..d212721 100644 --- a/lib/widgets/messages/messages_content.dart +++ b/lib/widgets/messages/messages_content.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; import '../../models/message.dart'; +import '../common/bidirectional_refresh.dart'; import '../../widgets/messages/message_bubble.dart'; class MessagesContent extends StatelessWidget { @@ -30,14 +31,18 @@ class MessagesContent extends StatelessWidget { @override Widget build(BuildContext context) { - return RefreshIndicator( + const scrollPhysics = BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ); + + return BidirectionalRefresh( onRefresh: onRefresh, child: messages.isEmpty ? LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - physics: const AlwaysScrollableScrollPhysics(), + physics: scrollPhysics, child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Center( @@ -69,6 +74,7 @@ class MessagesContent extends StatelessWidget { : ListView.builder( controller: scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: scrollPhysics, reverse: true, padding: EdgeInsets.fromLTRB( defaultPadding, diff --git a/test/widgets/bidirectional_refresh_test.dart b/test/widgets/bidirectional_refresh_test.dart new file mode 100644 index 0000000..e6b8918 --- /dev/null +++ b/test/widgets/bidirectional_refresh_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/widgets/common/bidirectional_refresh.dart'; + +void main() { + Future pumpRefreshHarness( + WidgetTester tester, { + required Future Function() onRefresh, + }) { + late BuildContext childContext; + + return tester + .pumpWidget( + MaterialApp( + home: Scaffold( + body: BidirectionalRefresh( + onRefresh: onRefresh, + child: Builder( + builder: (context) { + childContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ), + ) + .then((_) => childContext); + } + + OverscrollNotification topOverscroll(BuildContext context) { + return OverscrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 500, + pixels: 0, + viewportDimension: 300, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: context, + overscroll: -120, + ); + } + + OverscrollNotification bottomOverscroll(BuildContext context) { + return OverscrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 500, + pixels: 500, + viewportDimension: 300, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: context, + overscroll: 120, + ); + } + + testWidgets('refresh triggers from top overscroll', (tester) async { + var refreshCount = 0; + final childContext = await pumpRefreshHarness( + tester, + onRefresh: () async { + refreshCount++; + }, + ); + + topOverscroll(childContext).dispatch(childContext); + await tester.pump(); + + expect(refreshCount, 1); + }); + + testWidgets('refresh triggers from bottom overscroll', (tester) async { + var refreshCount = 0; + final childContext = await pumpRefreshHarness( + tester, + onRefresh: () async { + refreshCount++; + }, + ); + + bottomOverscroll(childContext).dispatch(childContext); + await tester.pump(); + + expect(refreshCount, 1); + }); +} diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index 45897de..df7a906 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -95,4 +95,13 @@ void main() { expect(find.text('Rescue One'), findsOneWidget); expect(find.text('John Smith'), findsNothing); }); + + testWidgets('hides public key in contact tile', (tester) async { + final contact = buildContact(name: 'John Smith', type: ContactType.chat); + + await pumpTile(tester, contact); + + expect(find.text(contact.publicKeyShort), findsNothing); + expect(find.byIcon(Icons.key_outlined), findsNothing); + }); }