dev
dart 220 lines 6.47 KB
Raw
1 import 'dart:math';
2
3 import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
4 import 'package:flutter/material.dart';
5
6 class ConfirmSwiper extends StatefulWidget {
7 final VoidCallback onConfirmed;
8 final String swiperText;
9
10 /// Label of the plain button rendered instead of the swiper when the platform
11 /// reports accessible navigation (VoiceOver / TalkBack), which intercepts the
12 /// horizontal drag the swiper depends on. Falls back to [swiperText].
13 final String? accessibleNavigationModeButtonText;
14
15 const ConfirmSwiper({
16 super.key,
17 required this.onConfirmed,
18 required this.swiperText,
19 this.accessibleNavigationModeButtonText,
20 });
21
22 @override
23 State<ConfirmSwiper> createState() => _ConfirmSwiperState();
24 }
25
26 class _ConfirmSwiperState extends State<ConfirmSwiper> {
27 double pillHorizontalPadding = 4;
28 double pillVerticalPadding = 2;
29 double pillSize = 48;
30 late double drag;
31
32 @override
33 void initState() {
34 super.initState();
35 setState(() {
36 drag = pillHorizontalPadding;
37 });
38 }
39
40 static const double _triggerThreshold = 20;
41
42 @override
43 Widget build(BuildContext context) {
44 if (MediaQuery.of(context).accessibleNavigation) {
45 return NewPrimaryButton(
46 onPressed: widget.onConfirmed,
47 text: widget.accessibleNavigationModeButtonText ?? widget.swiperText,
48 color: Theme.of(context).colorScheme.primary,
49 textColor: Theme.of(context).colorScheme.onPrimary,
50 );
51 }
52
53 final radius = (pillSize + pillHorizontalPadding * 2) / 2;
54
55 return LayoutBuilder(
56 builder: (context, constraints) {
57 final areaWidth = constraints.maxWidth.isFinite
58 ? constraints.maxWidth
59 : MediaQuery.of(context).size.width * 0.77;
60
61 final maxDrag = areaWidth - pillSize - pillHorizontalPadding;
62 final triggerAt = maxDrag - _triggerThreshold;
63
64 final swiper = GestureDetector(
65 onHorizontalDragUpdate: (d) {
66 setState(() {
67 drag = max(pillHorizontalPadding, min(maxDrag, drag + d.delta.dx));
68 });
69 },
70 onHorizontalDragEnd: (_) {
71 if (drag >= triggerAt) {
72 widget.onConfirmed();
73 }
74 setState(() => drag = pillHorizontalPadding);
75 },
76 child: SizedBox(
77 height: pillSize + pillHorizontalPadding * 2,
78 width: areaWidth,
79 child: Stack(
80 alignment: Alignment.centerLeft,
81 children: [
82 ClipPath(
83 clipper: TrackClipper(
84 cut: drag - pillHorizontalPadding,
85 radius: radius,
86 ),
87 child: Container(
88 height: pillSize + pillHorizontalPadding * 2,
89 width: areaWidth,
90 decoration: BoxDecoration(
91 color: Theme.of(context).colorScheme.surfaceContainerLowest,
92 borderRadius: BorderRadius.circular(99999),
93 ),
94 child: Center(
95 child: FlowingText(
96 text: widget.swiperText,
97 opacity: 1 - (drag / maxDrag),
98 ),
99 ),
100 ),
101 ),
102 Positioned(
103 left: drag,
104 child: Container(
105 height: pillSize,
106 width: pillSize,
107 decoration: BoxDecoration(
108 color: Theme.of(context).colorScheme.primary,
109 shape: BoxShape.circle,
110 ),
111 child: Icon(
112 Icons.arrow_forward,
113 color: Theme.of(context).colorScheme.onPrimary,
114 ),
115 ),
116 ),
117 ],
118 ),
119 ),
120 );
121
122 // A single accessibility node for the whole control: the flowing label and
123 // the arrow knob are decorative and must not become separate stops. No
124 // semantics action is exposed here; a screen reader gets the button
125 // variant above instead.
126 return Semantics(
127 container: true,
128 excludeSemantics: true,
129 label: widget.swiperText,
130 child: swiper,
131 );
132 },
133 );
134 }
135 }
136
137 class TrackClipper extends CustomClipper<Path> {
138 final double cut;
139 final double radius;
140
141 TrackClipper({required this.cut, required this.radius});
142
143 @override
144 Path getClip(Size size) {
145 final left = cut.clamp(0.0, size.width);
146 final rect = Rect.fromLTRB(left, 0, size.width, size.height);
147 return Path()..addRRect(RRect.fromRectAndRadius(rect, Radius.circular(radius)));
148 }
149
150 @override
151 bool shouldReclip(covariant TrackClipper oldClipper) {
152 return oldClipper.cut != cut;
153 }
154 }
155
156 class FlowingText extends StatefulWidget {
157 const FlowingText({super.key, required this.text, required this.opacity});
158
159 final String text;
160 final double opacity;
161
162 @override
163 State<FlowingText> createState() => _FlowingTextState();
164 }
165
166 class _FlowingTextState extends State<FlowingText> with SingleTickerProviderStateMixin {
167 late AnimationController controller;
168
169 @override
170 void initState() {
171 super.initState();
172 controller = AnimationController(
173 vsync: this,
174 duration: const Duration(milliseconds: 2400),
175 lowerBound: -1.0,
176 upperBound: 2.0)
177 ..repeat();
178 }
179
180 @override
181 void dispose() {
182 controller.dispose();
183 super.dispose();
184 }
185
186 @override
187 Widget build(BuildContext context) {
188 return Opacity(
189 opacity: widget.opacity,
190 child: AnimatedBuilder(
191 animation: controller,
192 builder: (_, __) {
193 return ShaderMask(
194 shaderCallback: (r) {
195 return LinearGradient(
196 begin: Alignment(-1 + (controller.value) * 2, 0),
197 end: Alignment(controller.value * 2, 0),
198 colors: [
199 Colors.transparent,
200 Theme.of(context).colorScheme.surfaceContainerHighest,
201 Colors.transparent
202 ],
203 ).createShader(r);
204 },
205 blendMode: BlendMode.srcATop,
206 child: Text(
207 widget.text,
208 style: TextStyle(
209 color: Theme.of(context).colorScheme.primary,
210 fontWeight: FontWeight.w400,
211 fontSize: 16,
212 letterSpacing: 0.5,
213 ),
214 ),
215 );
216 },
217 ),
218 );
219 }
220 }