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,
|
||||
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 = <Widget>[
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
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('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