dev
dart 232 lines 8.27 KB
Raw
1 import 'package:cake_wallet/generated/i18n.dart';
2 import 'package:flutter/material.dart';
3 import 'package:flutter/rendering.dart';
4
5 class RecipientDotRow extends StatefulWidget {
6 const RecipientDotRow(
7 {super.key, required this.numDots, required this.selectedDot, required this.onSelected});
8
9 final int numDots;
10 final int selectedDot;
11 final Function(int) onSelected;
12
13 @override
14 State<RecipientDotRow> createState() => _RecipientDotRowState();
15 }
16
17 class _RecipientDotRowState extends State<RecipientDotRow> {
18 static const double _outputDotSize = 36;
19 static const double _outputDotSpacing = 8;
20 static const double _deadzoneSize = 0.35;
21 late ScrollController _outputDotsController;
22 bool _wasScrollable = false;
23
24 @override
25 void initState() {
26 super.initState();
27 _outputDotsController = ScrollController();
28 }
29
30 @override
31 void didUpdateWidget(RecipientDotRow oldWidget) {
32 super.didUpdateWidget(oldWidget);
33
34 if (widget.numDots != oldWidget.numDots) {
35 WidgetsBinding.instance.addPostFrameCallback((_) {
36 if (_outputDotsController.hasClients) {
37 _outputDotsController.animateTo(_outputDotsController.position.maxScrollExtent,
38 duration: Duration(milliseconds: 500), curve: Curves.easeOutCubic);
39 }
40 });
41 }
42 }
43
44 @override
45 Widget build(BuildContext context) {
46 return LayoutBuilder(
47 builder: (context, constraints) {
48 final viewportWidth = constraints.maxWidth;
49
50 final double itemExtent = _outputDotSize + _outputDotSpacing;
51 final totalContentWidth = (widget.numDots * itemExtent) - _outputDotSpacing;
52 final isScrollable = totalContentWidth > viewportWidth;
53
54 final sidePadding = isScrollable ? viewportWidth * (_deadzoneSize / 2) : 0.0;
55
56 if (isScrollable && !_wasScrollable) {
57 _outputDotsController.jumpTo((widget.numDots * (_deadzoneSize / 2)) * itemExtent -
58 (_outputDotSpacing * (_deadzoneSize + 1)));
59 }
60
61 _wasScrollable = isScrollable;
62
63 return SizedBox(
64 height: _outputDotSize,
65 width: double.infinity,
66 child: Center(
67 child: AnimatedSize(
68 duration: const Duration(milliseconds: 150),
69 curve: Curves.easeOutCubic,
70 alignment: Alignment.centerLeft,
71 child: ListView.builder(
72 key: ValueKey(_outputDotsController),
73 controller: _outputDotsController,
74 scrollDirection: Axis.horizontal,
75 shrinkWrap: true,
76 padding: EdgeInsets.symmetric(horizontal: sidePadding),
77 physics: BouncingScrollPhysics(),
78 itemCount: widget.numDots,
79 itemBuilder: (context, index) {
80 return AnimatedBuilder(
81 animation: _outputDotsController,
82 builder: (context, child) {
83 double scale = 1.0;
84
85 if (isScrollable) {
86 final RenderBox? box = context.findRenderObject() as RenderBox?;
87 final RenderBox? viewportBox = context
88 .findAncestorRenderObjectOfType<RenderProxyBoxWithHitTestBehavior>();
89
90 if (box != null && viewportBox != null) {
91 final dotPosition = box.localToGlobal(Offset.zero, ancestor: viewportBox);
92 final dotCenter = dotPosition.dx + (_outputDotSize / 2);
93 final viewportCenter = viewportWidth / 2;
94 final distance = (dotCenter - viewportCenter).abs();
95 final threshold = viewportWidth * _deadzoneSize;
96 final maxDistance = viewportWidth / 2;
97
98 if (distance > threshold) {
99 final t = ((distance - threshold) / (maxDistance - threshold))
100 .clamp(0.0, 1.0);
101 scale = 1.0 - (t * 0.6);
102 }
103 } else {
104 if (index == widget.numDots - 1) scale = 0.0;
105 }
106 }
107
108 return Transform.scale(
109 scale: scale,
110 child: RecipientDot(
111 textOpacity: scale,
112 size: _outputDotSize,
113 spacing: _outputDotSpacing,
114 index: index,
115 onTap: () {
116 if (scale != 1.0) {
117 _outputDotsController.animateTo(index * _outputDotSize,
118 duration: Duration(milliseconds: 300),
119 curve: Curves.easeOutCubic);
120 }
121 widget.onSelected(index);
122 },
123 selected: index == widget.selectedDot,
124 ));
125 },
126 );
127 },
128 ),
129 ),
130 ),
131 );
132 },
133 );
134 }
135 }
136
137 class RecipientDot extends StatefulWidget {
138 const RecipientDot(
139 {super.key,
140 required this.size,
141 required this.spacing,
142 required this.index,
143 required this.onTap,
144 required this.selected,
145 required this.textOpacity});
146
147 final double size;
148 final double spacing;
149 final double textOpacity;
150 final int index;
151 final VoidCallback onTap;
152 final bool selected;
153
154 @override
155 State<RecipientDot> createState() => _RecipientDotState();
156 }
157
158 class _RecipientDotState extends State<RecipientDot> {
159 bool _created = false;
160
161 @override
162 void initState() {
163 super.initState();
164 WidgetsBinding.instance.addPostFrameCallback((_) {
165 setState(() {
166 _created = true;
167 });
168 });
169 }
170
171 @override
172 Widget build(BuildContext context) {
173 return SizedBox(
174 width: widget.size + widget.spacing,
175 height: widget.size,
176 child: Stack(
177 children: [
178 AnimatedPositioned(
179 duration: const Duration(milliseconds: 150),
180 curve: Curves.easeOutCubic,
181 top: _created ? 0 : widget.size,
182 child: Material(
183 // Dots scaled all the way down are not visible, so they must not be
184 // reachable by a screen reader either.
185 child: ExcludeSemantics(
186 excluding: widget.textOpacity <= 0,
187 child: Semantics(
188 button: true,
189 selected: widget.selected,
190 label: S.of(context).recipient_number("${widget.index + 1}"),
191 onTap: widget.onTap,
192 excludeSemantics: true,
193 child: AnimatedSwitcher(
194 duration: Duration(milliseconds: 150),
195 child: GestureDetector(
196 key: ValueKey(widget.selected),
197 onTap: widget.onTap,
198 child: Container(
199 width: widget.size,
200 height: widget.size,
201 margin: EdgeInsets.symmetric(horizontal: widget.spacing / 2),
202 decoration: BoxDecoration(
203 color: widget.selected
204 ? Theme.of(context).colorScheme.primary
205 : Theme.of(context).colorScheme.surfaceContainer,
206 shape: BoxShape.circle,
207 ),
208 alignment: Alignment.center,
209 child: Opacity(
210 opacity: widget.textOpacity,
211 child: Text(
212 (widget.index + 1).toString(),
213 style: TextStyle(
214 color: widget.selected
215 ? Theme.of(context).colorScheme.onPrimary
216 : Theme.of(context).colorScheme.onSurface,
217 fontSize: 14,
218 fontWeight: FontWeight.w500),
219 ),
220 ),
221 ),
222 ),
223 ),
224 ),
225 ),
226 ),
227 )
228 ],
229 ),
230 );
231 }
232 }