Add bidirectional refresh physics

This commit is contained in:
Janez T
2026-03-13 10:31:37 +01:00
parent 77e43843bf
commit 90877a1891
5 changed files with 283 additions and 14 deletions

View File

@@ -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<void> Function() onRefresh;
final double triggerDistance;
const BidirectionalRefresh({
super.key,
required this.child,
required this.onRefresh,
this.triggerDistance = defaultTriggerDistance,
});
@override
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
}
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
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<void> _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<ScrollNotification>(
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),
),
),
),
);
}
}

View File

@@ -145,12 +145,6 @@ class ContactTile extends StatelessWidget {
icon: Icons.folder_copy_outlined, icon: Icons.folder_copy_outlined,
label: label, label: label,
), ),
_buildMetaPill(
context,
icon: Icons.key_outlined,
label: contact.publicKeyShort,
monospace: true,
),
], ],
), ),
if (location != null) ...[ if (location != null) ...[
@@ -303,12 +297,6 @@ class ContactTile extends StatelessWidget {
final compactPills = <Widget>[ final compactPills = <Widget>[
if (groupLabel case final label?) if (groupLabel case final label?)
_buildMetaPill(context, icon: Icons.folder_copy_outlined, label: 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 (distanceText != null) _buildDistancePill(context, distanceText),
if (contact.routeHasPath && contact.routeHopCount > 0) if (contact.routeHasPath && contact.routeHopCount > 0)
_buildRoutePill(context, contact), _buildRoutePill(context, contact),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart'; import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget { class MessagesContent extends StatelessWidget {
@@ -30,14 +31,18 @@ class MessagesContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( const scrollPhysics = BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
return BidirectionalRefresh(
onRefresh: onRefresh, onRefresh: onRefresh,
child: messages.isEmpty child: messages.isEmpty
? LayoutBuilder( ? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView( builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior: keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag, ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(), physics: scrollPhysics,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight), constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center( child: Center(
@@ -69,6 +74,7 @@ class MessagesContent extends StatelessWidget {
: ListView.builder( : ListView.builder(
controller: scrollController, controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: scrollPhysics,
reverse: true, reverse: true,
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
defaultPadding, defaultPadding,

View File

@@ -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<BuildContext> pumpRefreshHarness(
WidgetTester tester, {
required Future<void> 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);
});
}

View File

@@ -95,4 +95,13 @@ void main() {
expect(find.text('Rescue One'), findsOneWidget); expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing); 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);
});
} }