| 1 | import 'package:cake_wallet/utils/device_info.dart'; |
| 2 | import 'package:cake_wallet/utils/responsive_layout_util.dart'; |
| 3 | import 'package:cake_wallet/utils/show_bar.dart'; |
| 4 | import 'package:another_flushbar/flushbar.dart'; |
| 5 | import 'package:flutter/material.dart'; |
| 6 | import 'package:cake_wallet/generated/i18n.dart'; |
| 7 | import 'package:flutter/services.dart'; |
| 8 | |
| 9 | class PinCodeWidget extends StatefulWidget { |
| 10 | PinCodeWidget({ |
| 11 | required Key key, |
| 12 | required this.onFullPin, |
| 13 | required this.initialPinLength, |
| 14 | required this.onChangedPin, |
| 15 | required this.hasLengthSwitcher, |
| 16 | this.onChangedPinLength, |
| 17 | }) : super(key: key); |
| 18 | |
| 19 | final void Function(String pin, PinCodeState state) onFullPin; |
| 20 | final void Function(String pin) onChangedPin; |
| 21 | final void Function(int length)? onChangedPinLength; |
| 22 | final bool hasLengthSwitcher; |
| 23 | final int initialPinLength; |
| 24 | |
| 25 | @override |
| 26 | State<StatefulWidget> createState() => PinCodeState(); |
| 27 | } |
| 28 | |
| 29 | class PinCodeState<T extends PinCodeWidget> extends State<T> { |
| 30 | PinCodeState() |
| 31 | : _aspectRatio = 0, |
| 32 | pinLength = 0, |
| 33 | pin = '', |
| 34 | title = ''; |
| 35 | static const defaultPinLength = fourPinLength; |
| 36 | static const sixPinLength = 6; |
| 37 | static const fourPinLength = 4; |
| 38 | final _gridViewKey = GlobalKey(); |
| 39 | final _key = GlobalKey<ScaffoldState>(); |
| 40 | late final FocusNode _focusNode; |
| 41 | |
| 42 | int pinLength; |
| 43 | String pin; |
| 44 | String title; |
| 45 | double _aspectRatio; |
| 46 | Flushbar<void>? _progressBar; |
| 47 | |
| 48 | int currentPinLength() => pin.length; |
| 49 | |
| 50 | @override |
| 51 | void initState() { |
| 52 | super.initState(); |
| 53 | pinLength = widget.initialPinLength; |
| 54 | pin = ''; |
| 55 | title = S.current.enter_your_pin; |
| 56 | _aspectRatio = 0; |
| 57 | _focusNode = FocusNode(); |
| 58 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 59 | _focusNode.requestFocus(); |
| 60 | _afterLayout(_); |
| 61 | }); |
| 62 | } |
| 63 | |
| 64 | @override |
| 65 | void dispose() { |
| 66 | _focusNode.dispose(); |
| 67 | super.dispose(); |
| 68 | } |
| 69 | |
| 70 | void setTitle(String title) => setState(() => this.title = title); |
| 71 | |
| 72 | void clear() => setState(() => pin = ''); |
| 73 | |
| 74 | void reset() => setState(() { |
| 75 | pin = ''; |
| 76 | pinLength = widget.initialPinLength; |
| 77 | title = S.current.enter_your_pin; |
| 78 | }); |
| 79 | |
| 80 | void changePinLength(int length) { |
| 81 | setState(() { |
| 82 | pinLength = length; |
| 83 | pin = ''; |
| 84 | }); |
| 85 | |
| 86 | widget.onChangedPinLength?.call(length); |
| 87 | } |
| 88 | |
| 89 | void setDefaultPinLength() => changePinLength(widget.initialPinLength); |
| 90 | |
| 91 | void calculateAspectRatio() { |
| 92 | final renderBox = _gridViewKey.currentContext!.findRenderObject() as RenderBox; |
| 93 | final cellWidth = renderBox.size.width / 3; |
| 94 | final cellHeight = renderBox.size.height / 4; |
| 95 | |
| 96 | if (cellWidth > 0 && cellHeight > 0) { |
| 97 | _aspectRatio = cellWidth / cellHeight; |
| 98 | } |
| 99 | |
| 100 | setState(() {}); |
| 101 | } |
| 102 | |
| 103 | void changeProcessText(String text) { |
| 104 | hideProgressText(); |
| 105 | _progressBar = createBar<void>(text, context, duration: null)..show(_key.currentContext!); |
| 106 | } |
| 107 | |
| 108 | void close() { |
| 109 | _progressBar?.dismiss(); |
| 110 | Navigator.of(_key.currentContext!).pop(); |
| 111 | } |
| 112 | |
| 113 | void hideProgressText() { |
| 114 | _progressBar?.dismiss(); |
| 115 | _progressBar = null; |
| 116 | } |
| 117 | |
| 118 | @override |
| 119 | Widget build(BuildContext context) => |
| 120 | Scaffold(key: _key, body: body(context), resizeToAvoidBottomInset: false); |
| 121 | |
| 122 | Widget body(BuildContext context) { |
| 123 | final deleteIconImage = Image.asset( |
| 124 | 'assets/images/delete_icon.png', |
| 125 | color: Theme.of(context).colorScheme.primary, |
| 126 | ); |
| 127 | |
| 128 | return KeyboardListener( |
| 129 | focusNode: _focusNode, |
| 130 | autofocus: false, |
| 131 | onKeyEvent: (keyEvent) { |
| 132 | if (keyEvent is KeyDownEvent) { |
| 133 | if (keyEvent.logicalKey.keyLabel == "Backspace") { |
| 134 | _pop(); |
| 135 | return; |
| 136 | } |
| 137 | int? number = int.tryParse(keyEvent.character ?? ''); |
| 138 | if (number != null) { |
| 139 | _push(number); |
| 140 | } |
| 141 | } |
| 142 | }, |
| 143 | child: Container( |
| 144 | color: Theme.of(context).colorScheme.surface, |
| 145 | padding: EdgeInsets.only(left: 44.0, right: 44.0, bottom: 60.0), |
| 146 | child: Column( |
| 147 | children: <Widget>[ |
| 148 | Spacer(flex: 2), |
| 149 | Text( |
| 150 | title, |
| 151 | style: Theme.of(context).textTheme.titleLarge?.copyWith( |
| 152 | fontWeight: FontWeight.w500, |
| 153 | fontSize: 20, |
| 154 | color: Theme.of(context).colorScheme.onSurface, |
| 155 | ), |
| 156 | ), |
| 157 | Spacer(flex: 2), |
| 158 | Container( |
| 159 | width: 180, |
| 160 | child: Row( |
| 161 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 162 | children: List.generate(pinLength, (index) { |
| 163 | const size = 10.0; |
| 164 | final isFilled = pin.length > index ? pin[index] != null : false; |
| 165 | |
| 166 | return Container( |
| 167 | width: size, |
| 168 | height: size, |
| 169 | decoration: BoxDecoration( |
| 170 | shape: BoxShape.circle, |
| 171 | color: isFilled |
| 172 | ? Theme.of(context).colorScheme.primary |
| 173 | : Theme.of(context).colorScheme.primary.withValues(alpha: 0.25), |
| 174 | )); |
| 175 | }), |
| 176 | ), |
| 177 | ), |
| 178 | Spacer(flex: 3), |
| 179 | if (widget.hasLengthSwitcher) ...[ |
| 180 | TextButton( |
| 181 | onPressed: () { |
| 182 | changePinLength(pinLength == PinCodeState.fourPinLength |
| 183 | ? PinCodeState.sixPinLength |
| 184 | : PinCodeState.fourPinLength); |
| 185 | }, |
| 186 | child: Text( |
| 187 | _changePinLengthText(), |
| 188 | style: Theme.of(context).textTheme.bodyMedium?.copyWith( |
| 189 | color: Theme.of(context).colorScheme.secondary, |
| 190 | ), |
| 191 | ), |
| 192 | ) |
| 193 | ], |
| 194 | Spacer(flex: 1), |
| 195 | Flexible( |
| 196 | flex: 24, |
| 197 | child: Center( |
| 198 | child: ConstrainedBox( |
| 199 | constraints: BoxConstraints( |
| 200 | maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint, |
| 201 | ), |
| 202 | child: Container( |
| 203 | key: _gridViewKey, |
| 204 | child: _aspectRatio > 0 |
| 205 | ? ScrollConfiguration( |
| 206 | behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), |
| 207 | child: GridView.count( |
| 208 | shrinkWrap: true, |
| 209 | crossAxisCount: 3, |
| 210 | childAspectRatio: DeviceInfo.instance.isDesktop ? _aspectRatio : 1.1, |
| 211 | physics: const NeverScrollableScrollPhysics(), |
| 212 | children: List.generate(12, (index) { |
| 213 | const double marginRight = 10; |
| 214 | const double marginLeft = 10; |
| 215 | |
| 216 | if (index == 9) { |
| 217 | // Empty container |
| 218 | return Container( |
| 219 | margin: EdgeInsets.only(left: marginLeft, right: marginRight), |
| 220 | ); |
| 221 | } else if (index == 10) { |
| 222 | index = 0; |
| 223 | } else if (index == 11) { |
| 224 | return MergeSemantics( |
| 225 | child: Container( |
| 226 | margin: EdgeInsets.only(left: marginLeft, right: marginRight), |
| 227 | child: Semantics( |
| 228 | label: S.of(context).delete, |
| 229 | button: true, |
| 230 | onTap: () => _pop(), |
| 231 | child: TextButton( |
| 232 | onPressed: () => {_pop(), HapticFeedback.mediumImpact()}, |
| 233 | style: TextButton.styleFrom( |
| 234 | backgroundColor: |
| 235 | Theme.of(context).colorScheme.surfaceContainer, |
| 236 | shape: CircleBorder(), |
| 237 | ), |
| 238 | child: deleteIconImage, |
| 239 | ), |
| 240 | ), |
| 241 | ), |
| 242 | ); |
| 243 | } else { |
| 244 | index++; |
| 245 | } |
| 246 | |
| 247 | return Container( |
| 248 | margin: EdgeInsets.only(left: marginLeft, right: marginRight), |
| 249 | child: TextButton( |
| 250 | key: ValueKey('pin_code_button_${index}_key'), |
| 251 | onPressed: () => {_push(index), HapticFeedback.mediumImpact()}, |
| 252 | style: TextButton.styleFrom( |
| 253 | backgroundColor: |
| 254 | Theme.of(context).colorScheme.surfaceContainer, |
| 255 | shape: CircleBorder(), |
| 256 | ), |
| 257 | child: Text('$index', |
| 258 | style: Theme.of(context).textTheme.headlineMedium?.copyWith( |
| 259 | fontWeight: FontWeight.w600, |
| 260 | fontSize: 30, |
| 261 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 262 | )), |
| 263 | ), |
| 264 | ); |
| 265 | }), |
| 266 | ), |
| 267 | ) |
| 268 | : null, |
| 269 | ), |
| 270 | ), |
| 271 | ), |
| 272 | ) |
| 273 | ], |
| 274 | ), |
| 275 | ), |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | void _push(int num) { |
| 280 | setState(() { |
| 281 | if (currentPinLength() >= pinLength) { |
| 282 | return; |
| 283 | } |
| 284 | |
| 285 | pin += num.toString(); |
| 286 | |
| 287 | widget.onChangedPin(pin); |
| 288 | |
| 289 | if (pin.length == pinLength) { |
| 290 | widget.onFullPin(pin, this); |
| 291 | } |
| 292 | }); |
| 293 | } |
| 294 | |
| 295 | void _pop() { |
| 296 | if (currentPinLength() == 0) { |
| 297 | return; |
| 298 | } |
| 299 | |
| 300 | setState(() => pin = pin.substring(0, pin.length - 1)); |
| 301 | } |
| 302 | |
| 303 | String _changePinLengthText() { |
| 304 | return S.current.use + |
| 305 | (pinLength == PinCodeState.fourPinLength |
| 306 | ? '${PinCodeState.sixPinLength}' |
| 307 | : '${PinCodeState.fourPinLength}') + |
| 308 | S.current.digit_pin; |
| 309 | } |
| 310 | |
| 311 | void _afterLayout(dynamic _) { |
| 312 | setDefaultPinLength(); |
| 313 | calculateAspectRatio(); |
| 314 | } |
| 315 | } |