mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add bidirectional refresh physics
This commit is contained in:
176
lib/widgets/common/bidirectional_refresh.dart
Normal file
176
lib/widgets/common/bidirectional_refresh.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
90
test/widgets/bidirectional_refresh_test.dart
Normal file
90
test/widgets/bidirectional_refresh_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user