Skip to content

Commit 3a9b4d4

Browse files
committed
feat: add shared animated refresh indicator
1 parent 402d8dc commit 3a9b4d4

17 files changed

Lines changed: 667 additions & 14 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import 'dart:math' as math;
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:spark/src/core/design_system/tokens/constants.dart';
5+
import 'package:spark/src/core/l10n/app_localizations.dart';
6+
7+
/// Spark's pull-to-refresh treatment. The child must contain a vertical
8+
/// scrollable; use AlwaysScrollableScrollPhysics for short or empty content.
9+
class DSRefreshIndicator extends StatefulWidget {
10+
const DSRefreshIndicator({
11+
required this.onRefresh,
12+
required this.child,
13+
this.edgeOffset = 0,
14+
super.key,
15+
}) : assert(edgeOffset >= 0);
16+
17+
final RefreshCallback onRefresh;
18+
final Widget child;
19+
20+
/// Top inset for the indicator only. The scrollable content is not inset.
21+
/// Pass MediaQuery.paddingOf(context).top when drawing behind system UI.
22+
final double edgeOffset;
23+
24+
@override
25+
State<DSRefreshIndicator> createState() => DSRefreshIndicatorState();
26+
}
27+
28+
class DSRefreshIndicatorState extends State<DSRefreshIndicator>
29+
with TickerProviderStateMixin {
30+
final _refreshKey = GlobalKey<RefreshIndicatorState>();
31+
late final _reveal = AnimationController(
32+
vsync: this,
33+
duration: AppConstants.animationFast,
34+
);
35+
late final _ripple = AnimationController(
36+
vsync: this,
37+
duration: const Duration(milliseconds: 900),
38+
);
39+
RefreshIndicatorStatus? _status;
40+
double _pullExtent = 0;
41+
bool _reduceMotion = false;
42+
43+
bool get _refreshing =>
44+
_status == RefreshIndicatorStatus.snap ||
45+
_status == RefreshIndicatorStatus.refresh;
46+
47+
/// Runs the same refresh as a pull gesture, coalescing concurrent requests.
48+
Future<void> show() => _refreshKey.currentState!.show();
49+
50+
@override
51+
void didChangeDependencies() {
52+
super.didChangeDependencies();
53+
_reduceMotion = MediaQuery.disableAnimationsOf(context);
54+
_updateRipple();
55+
}
56+
57+
void _updateRipple() {
58+
if (_refreshing && !_reduceMotion) {
59+
if (!_ripple.isAnimating) _ripple.repeat();
60+
} else {
61+
_ripple.stop();
62+
}
63+
}
64+
65+
void _onStatusChange(RefreshIndicatorStatus? status) {
66+
setState(() => _status = status);
67+
switch (status) {
68+
case RefreshIndicatorStatus.drag:
69+
_pullExtent = 0;
70+
_reveal.value = 0;
71+
_ripple.value = 0;
72+
case RefreshIndicatorStatus.armed:
73+
case RefreshIndicatorStatus.snap:
74+
case RefreshIndicatorStatus.refresh:
75+
_reveal.animateTo(1, curve: Curves.easeOutCubic);
76+
case RefreshIndicatorStatus.done:
77+
case RefreshIndicatorStatus.canceled:
78+
case null:
79+
_reveal.animateBack(0, curve: Curves.easeOutCubic);
80+
}
81+
_updateRipple();
82+
}
83+
84+
bool _onScroll(ScrollNotification notification) {
85+
// Flutter owns arming, cancellation and refresh lifetime. These deltas only
86+
// scrub the same ripple that continues on release; they never trigger a
87+
// refresh.
88+
if (notification.depth != 0 ||
89+
notification.metrics.axis != Axis.vertical ||
90+
(_status != RefreshIndicatorStatus.drag &&
91+
_status != RefreshIndicatorStatus.armed)) {
92+
return false;
93+
}
94+
final delta = switch (notification) {
95+
ScrollUpdateNotification(:final scrollDelta) => scrollDelta ?? 0,
96+
OverscrollNotification(:final overscroll) => overscroll,
97+
_ => 0.0,
98+
};
99+
final direction = notification.metrics.axisDirection == AxisDirection.down
100+
? -1
101+
: 1;
102+
_pullExtent = math.max(0, _pullExtent + delta * direction);
103+
final pullProgress = (_pullExtent / 120).clamp(0.0, 1.0);
104+
if (_status == RefreshIndicatorStatus.drag) {
105+
_reveal.value = pullProgress.clamp(0, 0.95);
106+
}
107+
if (!_reduceMotion) _ripple.value = pullProgress;
108+
return false;
109+
}
110+
111+
@override
112+
void dispose() {
113+
_reveal.dispose();
114+
_ripple.dispose();
115+
super.dispose();
116+
}
117+
118+
@override
119+
Widget build(BuildContext context) {
120+
final colors = Theme.of(context).colorScheme;
121+
final l10n = AppLocalizations.of(context);
122+
final label = switch (_status) {
123+
RefreshIndicatorStatus.snap ||
124+
RefreshIndicatorStatus.refresh ||
125+
RefreshIndicatorStatus.done => l10n.refreshIndicatorRefreshing,
126+
RefreshIndicatorStatus.armed => l10n.refreshIndicatorRelease,
127+
_ => l10n.refreshIndicatorPull,
128+
};
129+
130+
return Stack(
131+
children: [
132+
NotificationListener<ScrollNotification>(
133+
onNotification: _onScroll,
134+
child: RefreshIndicator.noSpinner(
135+
key: _refreshKey,
136+
onRefresh: widget.onRefresh,
137+
onStatusChange: _onStatusChange,
138+
child: widget.child,
139+
),
140+
),
141+
Positioned(
142+
top: widget.edgeOffset + 12,
143+
left: 0,
144+
right: 0,
145+
child: IgnorePointer(
146+
child: AnimatedBuilder(
147+
animation: _reveal,
148+
builder: (context, child) {
149+
final progress = _reveal.value;
150+
if (progress == 0) return const SizedBox.shrink();
151+
return Opacity(
152+
opacity: progress,
153+
child: Transform.translate(
154+
offset: Offset(0, _reduceMotion ? 0 : -12 * (1 - progress)),
155+
child: Center(child: child),
156+
),
157+
);
158+
},
159+
child: Semantics(
160+
label: label,
161+
liveRegion: true,
162+
child: RepaintBoundary(
163+
child: DecoratedBox(
164+
decoration: BoxDecoration(
165+
color: colors.surfaceContainerHighest,
166+
borderRadius: BorderRadius.circular(24),
167+
border: Border.all(color: colors.outlineVariant),
168+
),
169+
child: SizedBox(
170+
width: 64,
171+
height: 36,
172+
child: CustomPaint(
173+
painter: _RefreshDotsPainter(
174+
animation: _ripple,
175+
color: colors.primary,
176+
),
177+
),
178+
),
179+
),
180+
),
181+
),
182+
),
183+
),
184+
),
185+
],
186+
);
187+
}
188+
}
189+
190+
class _RefreshDotsPainter extends CustomPainter {
191+
_RefreshDotsPainter({required this.animation, required this.color})
192+
: super(repaint: animation);
193+
194+
final Animation<double> animation;
195+
final Color color;
196+
197+
@override
198+
void paint(Canvas canvas, Size size) {
199+
final paint = Paint();
200+
final phase = animation.value % 1;
201+
// Each cycle begins and ends with three matching dots at rest.
202+
final envelope = math.sin(phase * math.pi);
203+
final intensity = envelope * envelope;
204+
for (var i = 0; i < 3; i++) {
205+
final wave = (math.sin((phase - i * 0.16) * math.pi * 2) + 1) / 2;
206+
paint.color = color.withValues(alpha: 1 - intensity * (1 - wave) * 0.6);
207+
final center = Offset(size.width / 2 + (i - 1) * 12, size.height / 2);
208+
canvas.drawRRect(
209+
RRect.fromRectAndRadius(
210+
Rect.fromCenter(
211+
center: center,
212+
width: 6,
213+
height: 6 + intensity * wave * 6,
214+
),
215+
const Radius.circular(3),
216+
),
217+
paint,
218+
);
219+
}
220+
}
221+
222+
@override
223+
bool shouldRepaint(_RefreshDotsPainter oldDelegate) =>
224+
oldDelegate.color != color || oldDelegate.animation != animation;
225+
}

lib/src/core/design_system/templates/chat_list_page_template.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:skeletonizer/skeletonizer.dart';
33
import 'package:spark/src/core/design_system/components/atoms/icons.dart';
4+
import 'package:spark/src/core/design_system/components/atoms/refresh_indicator.dart';
45
import 'package:spark/src/core/design_system/components/atoms/user_avatar.dart';
56
import 'package:spark/src/core/design_system/tokens/typography.dart';
67

@@ -86,7 +87,7 @@ class ChatListPageTemplate extends StatelessWidget {
8687
Expanded(
8788
child: loading
8889
? _ChatListSkeleton(itemCount: loadingItemCount)
89-
: RefreshIndicator(
90+
: DSRefreshIndicator(
9091
onRefresh: onRefresh ?? () async {},
9192
child: ListView.separated(
9293
padding: EdgeInsets.zero,

lib/src/core/design_system/templates/profile_page_template.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:skeletonizer/skeletonizer.dart';
33
import 'package:spark/src/core/design_system/components/atoms/avatar_stack.dart';
44
import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart';
55
import 'package:spark/src/core/design_system/components/atoms/icons.dart';
6+
import 'package:spark/src/core/design_system/components/atoms/refresh_indicator.dart';
67
import 'package:spark/src/core/design_system/components/molecules/profile_action_buttons.dart';
78
import 'package:spark/src/core/design_system/components/molecules/profile_avatar.dart';
89
import 'package:spark/src/core/design_system/components/molecules/profile_info.dart';
@@ -100,7 +101,7 @@ class ProfilePageTemplate extends StatelessWidget {
100101
actions: appBarActions,
101102
leading: leading ?? const AppLeadingButton(),
102103
),
103-
body: RefreshIndicator(
104+
body: DSRefreshIndicator(
104105
onRefresh: onRefresh ?? () async {},
105106
child: NotificationListener<ScrollNotification>(
106107
onNotification: (notification) {

lib/src/core/l10n/app_localizations.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,6 +2203,24 @@ abstract class AppLocalizations {
22032203
/// In en, this message translates to:
22042204
/// **'Send'**
22052205
String get buttonSend;
2206+
2207+
/// Accessibility hint while pulling down to refresh
2208+
///
2209+
/// In en, this message translates to:
2210+
/// **'Pull to refresh'**
2211+
String get refreshIndicatorPull;
2212+
2213+
/// Accessibility announcement when pull to refresh is armed
2214+
///
2215+
/// In en, this message translates to:
2216+
/// **'Release to refresh'**
2217+
String get refreshIndicatorRelease;
2218+
2219+
/// Accessibility announcement while refreshing content
2220+
///
2221+
/// In en, this message translates to:
2222+
/// **'Refreshing'**
2223+
String get refreshIndicatorRefreshing;
22062224
}
22072225

22082226
class _AppLocalizationsDelegate

lib/src/core/l10n/app_localizations_en.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,4 +1248,13 @@ class AppLocalizationsEn extends AppLocalizations {
12481248

12491249
@override
12501250
String get buttonSend => 'Send';
1251+
1252+
@override
1253+
String get refreshIndicatorPull => 'Pull to refresh';
1254+
1255+
@override
1256+
String get refreshIndicatorRelease => 'Release to refresh';
1257+
1258+
@override
1259+
String get refreshIndicatorRefreshing => 'Refreshing';
12511260
}

lib/src/core/l10n/intl_en.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,5 +1862,17 @@
18621862
"buttonSend": "Send",
18631863
"@buttonSend": {
18641864
"description": "Send button text"
1865+
},
1866+
"refreshIndicatorPull": "Pull to refresh",
1867+
"@refreshIndicatorPull": {
1868+
"description": "Accessibility hint while pulling down to refresh"
1869+
},
1870+
"refreshIndicatorRelease": "Release to refresh",
1871+
"@refreshIndicatorRelease": {
1872+
"description": "Accessibility announcement when pull to refresh is armed"
1873+
},
1874+
"refreshIndicatorRefreshing": "Refreshing",
1875+
"@refreshIndicatorRefreshing": {
1876+
"description": "Accessibility announcement while refreshing content"
18651877
}
18661878
}

lib/src/features/feed/ui/pages/feed_page.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:auto_route/auto_route.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter_riverpod/flutter_riverpod.dart';
6+
import 'package:spark/src/core/design_system/components/atoms/refresh_indicator.dart';
67
import 'package:spark/src/core/design_system/tokens/colors.dart';
78
import 'package:spark/src/core/l10n/app_localizations.dart';
89
import 'package:spark/src/core/network/atproto/data/models/feed_models.dart';
@@ -34,7 +35,7 @@ class FeedPage extends ConsumerStatefulWidget {
3435
class _FeedPageState extends ConsumerState<FeedPage>
3536
with AutomaticKeepAliveClientMixin {
3637
late final PageController pageController;
37-
final _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
38+
final _refreshIndicatorKey = GlobalKey<DSRefreshIndicatorState>();
3839
bool _hasInitialized = false;
3940
bool _isRefreshing = false;
4041
FeedActionControllerNotifier? _actionControllerNotifier;
@@ -271,8 +272,9 @@ class _FeedPageState extends ConsumerState<FeedPage>
271272
);
272273
}
273274

274-
return RefreshIndicator(
275+
return DSRefreshIndicator(
275276
key: _refreshIndicatorKey,
277+
edgeOffset: MediaQuery.paddingOf(context).top,
276278
onRefresh: onRefresh,
277279
child: content,
278280
);

lib/src/features/notifications/ui/widgets/notifications_list.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:spark/src/core/design_system/components/atoms/refresh_indicator.dart';
34
import 'package:spark/src/core/l10n/app_localizations.dart';
45
import 'package:spark/src/features/notifications/providers/notification_provider.dart';
56
import 'package:spark/src/features/notifications/ui/widgets/notification_item.dart';
@@ -161,7 +162,7 @@ class _NotificationsListState extends ConsumerState<NotificationsList> {
161162
final errorMsg = notificationState.errorMessage;
162163
final theme = Theme.of(context);
163164
final colorScheme = theme.colorScheme;
164-
return RefreshIndicator(
165+
return DSRefreshIndicator(
165166
onRefresh: () async {
166167
await ref
167168
.read(
@@ -232,7 +233,7 @@ class _NotificationsListState extends ConsumerState<NotificationsList> {
232233
if (notificationState.notifications.isEmpty) {
233234
final theme = Theme.of(context);
234235
final colorScheme = theme.colorScheme;
235-
return RefreshIndicator(
236+
return DSRefreshIndicator(
236237
onRefresh: () async {
237238
await ref
238239
.read(
@@ -286,7 +287,7 @@ class _NotificationsListState extends ConsumerState<NotificationsList> {
286287
_scheduleVisibilityCheck();
287288
});
288289

289-
return RefreshIndicator(
290+
return DSRefreshIndicator(
290291
onRefresh: () async {
291292
await ref
292293
.read(

lib/src/features/profile/ui/pages/blocks_page.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart';
5+
import 'package:spark/src/core/design_system/components/atoms/refresh_indicator.dart';
56
import 'package:spark/src/core/l10n/app_localizations.dart';
67
import 'package:spark/src/features/auth/providers/auth_providers.dart';
78
import 'package:spark/src/features/profile/providers/blocks_provider.dart';
@@ -64,7 +65,7 @@ class _BlocksPageState extends ConsumerState<BlocksPage> {
6465
leading: AppLeadingButton(tooltip: l10n.buttonCancel),
6566
title: Text(l10n.pageTitleBlockedUsers),
6667
),
67-
body: RefreshIndicator(
68+
body: DSRefreshIndicator(
6869
onRefresh: () async {
6970
ref.invalidate(blocksProvider(did: currentDid));
7071
await ref.read(blocksProvider(did: currentDid).future);

0 commit comments

Comments
 (0)