dev
dart 302 lines 11.1 KB
Raw
1 import 'dart:math' as math;
2 import 'dart:ui';
3 import 'package:cake_wallet/entities/page_indicator_actions.dart';
4 import 'package:flutter/material.dart';
5 import 'package:flutter_svg/flutter_svg.dart';
6 import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
7
8 class PageIndicator extends StatefulWidget {
9 const PageIndicator({
10 super.key,
11 required this.controller,
12 required this.dashboardViewModel,
13 });
14
15 final PageController controller;
16 final DashboardViewModel dashboardViewModel;
17
18 @override
19 State<PageIndicator> createState() => _PageIndicatorState();
20 }
21
22 class _PageIndicatorState extends State<PageIndicator> {
23 static const barHeight = 37.0;
24 static const iconWidth = 16.0;
25 static const iconHeight = 16.0;
26 static const iconSpacing = 5.0;
27 static const barHorizontalPadding = 4.0;
28 static const pillHorizontalPadding = 8.0;
29 static const edgePadding = 5.0;
30 static const barBorderRadius = 50.0;
31 static const pillBorderRadius = 50.0;
32 static const pageSwitchDuration = Duration(milliseconds: 300);
33 static const pillMoveDuration = Duration(milliseconds: 300);
34 static const pillResizeDuration = Duration(milliseconds: 200);
35 static const textStyle = TextStyle(
36 fontSize: 12,
37 fontWeight: FontWeight.w400,
38 );
39
40 late int selectedIndex;
41 late final VoidCallback _pageListener;
42
43 @override
44 void initState() {
45 super.initState();
46
47 selectedIndex = widget.controller.initialPage;
48
49 _pageListener = () {
50 final page = widget.controller.page;
51 if (page == null) return;
52
53 final rounded = page.round();
54 if (rounded != selectedIndex && mounted) {
55 setState(() {
56 selectedIndex = rounded;
57 });
58 }
59 };
60
61 widget.controller.addListener(_pageListener);
62 }
63
64 @override
65 void dispose() {
66 widget.controller.removeListener(_pageListener);
67 super.dispose();
68 }
69
70 void _onItemTap(int index) {
71 if (index == selectedIndex) return;
72 widget.controller.animateToPage(
73 index,
74 duration: pageSwitchDuration,
75 curve: Curves.easeOutCubic,
76 );
77 }
78
79 double _estimateItemWidthForAction(BuildContext context, PageIndicatorActions action,
80 {Color? color}) {
81 final text = action.name(context);
82 final textPainter = TextPainter(
83 text: TextSpan(text: text, style: textStyle.copyWith(color: color)),
84 maxLines: 1,
85 textDirection: Directionality.of(context),
86 textScaler: MediaQuery.of(context).textScaler,
87 )..layout();
88 return iconWidth + iconSpacing + textPainter.width + pillHorizontalPadding * 2;
89 }
90
91 @override
92 Widget build(BuildContext context) {
93 final visibleActions = PageIndicatorActions.all.where((action) {
94 return action.isEnabled?.call(widget.dashboardViewModel) ?? true;
95 }).toList();
96
97 // Don't show indicator if less than 2 actions
98 if (visibleActions.length < 2) return const SizedBox.shrink();
99
100 selectedIndex = selectedIndex.clamp(0, visibleActions.length - 1);
101
102 // Theme setup
103 final theme = Theme.of(context);
104 final backgroundColor = theme.colorScheme.surfaceContainer.withAlpha(122);
105 final pillColor = theme.colorScheme.onSurface.withAlpha(30);
106 final activeColor = theme.colorScheme.onSurface;
107 final inactiveColor = theme.colorScheme.primary;
108
109 // Size calculations
110 final screenWidth = MediaQuery.of(context).size.width;
111 final isRTL = Directionality.of(context) == TextDirection.rtl;
112
113 final pillWidth = _estimateItemWidthForAction(
114 context,
115 visibleActions[selectedIndex],
116 color: activeColor,
117 );
118
119 final actionWidths = [
120 for (final action in visibleActions)
121 _estimateItemWidthForAction(context, action, color: inactiveColor)
122 ];
123
124 final totalItemsWidth =
125 actionWidths.fold(0.0, (sum, width) => sum + width) + barHorizontalPadding * 2;
126
127 final barWidth = math.min(totalItemsWidth, screenWidth * 0.95);
128
129 final count = visibleActions.length;
130 final firstLeft = edgePadding;
131 final secondLeft = barWidth - (isRTL ? actionWidths.first : actionWidths[1]) - edgePadding;
132
133 final List<double> positions;
134
135 if (count == 2) {
136 positions = [firstLeft, secondLeft];
137 } else {
138 final lastLeft = barWidth - (isRTL ? actionWidths.first : actionWidths.last) - edgePadding;
139
140 final centerLeft =
141 (isRTL ? actionWidths.last + edgePadding : actionWidths.first + edgePadding);
142
143 positions = [firstLeft, centerLeft, lastLeft];
144 }
145
146 final left = (isRTL ? positions.reversed.toList() : positions)[selectedIndex];
147 final currentAction = visibleActions[selectedIndex];
148
149 return Align(
150 alignment: Alignment.bottomCenter,
151 child: Padding(
152 padding: EdgeInsets.only(top: 10),
153 child: Container(
154 width: barWidth,
155 child: ClipRRect(
156 borderRadius: BorderRadius.circular(barBorderRadius),
157 child: BackdropFilter(
158 filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
159 child: Container(
160 height: barHeight,
161 decoration: BoxDecoration(
162 color: backgroundColor,
163 borderRadius: BorderRadius.circular(barBorderRadius),
164 ),
165 child: Stack(
166 alignment: Alignment.center,
167 children: [
168 AnimatedPill(
169 left: left,
170 pillColor: pillColor,
171 currentAction: currentAction,
172 pillIconHeight: iconHeight,
173 pillIconWidth: iconWidth,
174 pillIconSpacing: iconSpacing,
175 pillBorderRadius: pillBorderRadius,
176 contentColor: activeColor,
177 estimateWidthForAction: pillWidth,
178 pillTextStyle: textStyle,
179 pillMoveDuration: pillMoveDuration,
180 pillResizeDuration: pillResizeDuration,
181 ),
182 Padding(
183 padding: const EdgeInsets.symmetric(horizontal: barHorizontalPadding),
184 child: Row(
185 mainAxisAlignment: MainAxisAlignment.spaceBetween,
186 children: [
187 for (int i = 0; i < visibleActions.length; i++)
188 Semantics(
189 button: true,
190 label: visibleActions[i].name(context),
191 value: i == selectedIndex ? 'Selected' : null,
192 hint: 'Double tap to open ${visibleActions[i].name(context)} page',
193 onTap: () => _onItemTap(i),
194 child: GestureDetector(
195 behavior: HitTestBehavior.translucent,
196 onTap: () => _onItemTap(i),
197 child: Container(
198 height: barHeight,
199 width: i == selectedIndex
200 ? pillWidth
201 : _estimateItemWidthForAction(context, visibleActions[i]),
202 alignment: Alignment.center,
203 child: Row(
204 mainAxisSize: MainAxisSize.min,
205 children: [
206 TweenAnimationBuilder<Color?>(
207 duration: const Duration(milliseconds: 300),
208 curve: Curves.easeOutCubic,
209 tween: ColorTween(
210 begin: inactiveColor,
211 end: i == selectedIndex ? activeColor : inactiveColor,
212 ),
213 builder: (context, color, _) => SvgPicture.asset(
214 visibleActions[i].image,
215 width: iconWidth,
216 height: iconHeight,
217 colorFilter: ColorFilter.mode(color!, BlendMode.srcIn),
218 ),
219 ),
220 SizedBox(width: iconSpacing),
221 TweenAnimationBuilder<Color?>(
222 duration: const Duration(milliseconds: 300),
223 curve: Curves.easeOutCubic,
224 tween: ColorTween(
225 begin: inactiveColor,
226 end: i == selectedIndex ? activeColor : inactiveColor,
227 ),
228 builder: (context, color, _) => Text(
229 visibleActions[i].name(context),
230 style: textStyle.copyWith(color: color),
231 overflow: TextOverflow.fade,
232 softWrap: false,
233 ),
234 ),
235 ],
236 ),
237 ),
238 ),
239 ),
240 ],
241 ),
242 )
243 ],
244 )),
245 ),
246 ),
247 ),
248 ),
249 );
250 }
251 }
252
253 class AnimatedPill extends StatelessWidget {
254 const AnimatedPill({
255 super.key,
256 required this.left,
257 required this.pillColor,
258 required this.contentColor,
259 required this.pillIconHeight,
260 required this.pillIconWidth,
261 required this.pillBorderRadius,
262 required this.currentAction,
263 required this.estimateWidthForAction,
264 required this.pillIconSpacing,
265 required this.pillTextStyle,
266 required this.pillMoveDuration,
267 required this.pillResizeDuration,
268 });
269
270 final double left;
271 final Color pillColor;
272 final Color contentColor;
273 final double pillIconHeight;
274 final double pillIconWidth;
275 final double pillBorderRadius;
276 final PageIndicatorActions currentAction;
277 final double estimateWidthForAction;
278 final double pillIconSpacing;
279 final TextStyle pillTextStyle;
280 final Duration pillMoveDuration;
281 final Duration pillResizeDuration;
282
283 @override
284 Widget build(BuildContext context) {
285 return AnimatedPositioned(
286 duration: pillMoveDuration,
287 curve: Curves.easeOutCubic,
288 left: left,
289 top: 4,
290 bottom: 4,
291 child: AnimatedContainer(
292 duration: pillResizeDuration,
293 curve: Curves.easeOutCubic,
294 width: estimateWidthForAction,
295 decoration:
296 BoxDecoration(color: pillColor, borderRadius: BorderRadius.circular(pillBorderRadius)),
297 clipBehavior: Clip.hardEdge,
298 alignment: Alignment.center,
299 ),
300 );
301 }
302 }