pin_setup_screen.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/widgets/app_icon.dart';
  6. import '../../core/widgets/pressable.dart';
  7. import 'lock_controller.dart';
  8. import 'pin_pad.dart';
  9. enum PinSetupMode { set, change, remove }
  10. /// Set, change or remove the four-digit PIN. The PIN is only used when the
  11. /// launcher is cold-started; this screen never locks the current session.
  12. class PinSetupScreen extends StatefulWidget {
  13. const PinSetupScreen({super.key, required this.mode});
  14. final PinSetupMode mode;
  15. @override
  16. State<PinSetupScreen> createState() => _PinSetupScreenState();
  17. }
  18. class _PinSetupScreenState extends State<PinSetupScreen>
  19. with SingleTickerProviderStateMixin {
  20. String _pin = '';
  21. int _step = 0;
  22. String? _firstPin;
  23. String? _currentPin;
  24. bool _busy = false;
  25. String? _error;
  26. late final AnimationController _shake = AnimationController(
  27. vsync: this,
  28. duration: const Duration(milliseconds: 380),
  29. );
  30. @override
  31. void dispose() {
  32. _shake.dispose();
  33. super.dispose();
  34. }
  35. int get _stepCount {
  36. switch (widget.mode) {
  37. case PinSetupMode.set:
  38. return 2;
  39. case PinSetupMode.change:
  40. return 3;
  41. case PinSetupMode.remove:
  42. return 1;
  43. }
  44. }
  45. String get _title {
  46. switch (widget.mode) {
  47. case PinSetupMode.set:
  48. return _step == 0 ? 'Choose a PIN' : 'Confirm your PIN';
  49. case PinSetupMode.change:
  50. return switch (_step) {
  51. 0 => 'Enter current PIN',
  52. 1 => 'Choose a new PIN',
  53. _ => 'Confirm your new PIN',
  54. };
  55. case PinSetupMode.remove:
  56. return 'Enter current PIN';
  57. }
  58. }
  59. String get _description {
  60. switch (widget.mode) {
  61. case PinSetupMode.set:
  62. return _step == 0
  63. ? 'Four digits. You will be asked for this the next time CarDash starts.'
  64. : 'Enter the same four digits again.';
  65. case PinSetupMode.change:
  66. return switch (_step) {
  67. 0 => 'Confirm your current PIN before choosing a new one.',
  68. 1 => 'Four digits. The new PIN is used the next time CarDash starts.',
  69. _ => 'Enter the new four digits again.',
  70. };
  71. case PinSetupMode.remove:
  72. return 'Confirm your current PIN. PIN protection will be turned off.';
  73. }
  74. }
  75. void _onDigit(String digit) {
  76. if (_busy || _pin.length >= 4) return;
  77. final lock = context.read<LockController>();
  78. if (lock.isCoolingDown) return;
  79. setState(() {
  80. _error = null;
  81. _pin += digit;
  82. });
  83. if (_pin.length == 4) {
  84. setState(() => _busy = true);
  85. Future<void>.delayed(const Duration(milliseconds: 110), _advance);
  86. }
  87. }
  88. void _onBackspace() {
  89. if (_busy || _pin.isEmpty) return;
  90. setState(() => _pin = _pin.substring(0, _pin.length - 1));
  91. }
  92. void _fail(String message) {
  93. setState(() {
  94. _pin = '';
  95. _busy = false;
  96. _error = message;
  97. });
  98. _shake.forward(from: 0);
  99. }
  100. Future<void> _advance() async {
  101. if (!mounted) return;
  102. final lock = context.read<LockController>();
  103. switch (widget.mode) {
  104. case PinSetupMode.set:
  105. if (_step == 0) {
  106. setState(() {
  107. _firstPin = _pin;
  108. _pin = '';
  109. _step = 1;
  110. _busy = false;
  111. _error = null;
  112. });
  113. return;
  114. }
  115. if (_pin != _firstPin) {
  116. _fail('PINs do not match.');
  117. return;
  118. }
  119. final error = await lock.setPin(_firstPin!);
  120. if (!mounted) return;
  121. if (error != null) {
  122. _fail(error);
  123. return;
  124. }
  125. Navigator.of(context).pop(true);
  126. case PinSetupMode.change:
  127. if (_step == 0) {
  128. final valid = await lock.verifyPin(_pin);
  129. if (!mounted) return;
  130. if (!valid) {
  131. _fail(lock.errorMessage ?? 'Current PIN is incorrect.');
  132. return;
  133. }
  134. setState(() {
  135. _currentPin = _pin;
  136. _pin = '';
  137. _step = 1;
  138. _busy = false;
  139. _error = null;
  140. });
  141. return;
  142. }
  143. if (_step == 1) {
  144. setState(() {
  145. _firstPin = _pin;
  146. _pin = '';
  147. _step = 2;
  148. _busy = false;
  149. _error = null;
  150. });
  151. return;
  152. }
  153. if (_pin != _firstPin) {
  154. _fail('PINs do not match.');
  155. return;
  156. }
  157. final error = await lock.changePin(
  158. currentPin: _currentPin!,
  159. newPin: _firstPin!,
  160. );
  161. if (!mounted) return;
  162. if (error != null) {
  163. _fail(error);
  164. return;
  165. }
  166. Navigator.of(context).pop(true);
  167. case PinSetupMode.remove:
  168. final error = await lock.removePin(_pin);
  169. if (!mounted) return;
  170. if (error != null) {
  171. _fail(error);
  172. return;
  173. }
  174. Navigator.of(context).pop(true);
  175. }
  176. }
  177. @override
  178. Widget build(BuildContext context) {
  179. final colors = context.colors;
  180. final theme = Theme.of(context);
  181. final lock = context.watch<LockController>();
  182. final error = lock.isCoolingDown
  183. ? 'Too many attempts. Try again in ${lock.cooldownSecondsRemaining}s.'
  184. : _error;
  185. return Scaffold(
  186. backgroundColor: colors.canvas,
  187. body: Container(
  188. decoration: BoxDecoration(
  189. gradient: LinearGradient(
  190. begin: Alignment.topLeft,
  191. end: Alignment.bottomRight,
  192. colors: Theme.of(context).brightness == Brightness.dark
  193. ? [colors.canvas, colors.canvasDeep]
  194. : [colors.canvas, const Color(0xFFF7F5EF)],
  195. ),
  196. ),
  197. child: SafeArea(
  198. child: Padding(
  199. padding: const EdgeInsets.fromLTRB(32, 22, 32, 22),
  200. child: Column(
  201. crossAxisAlignment: CrossAxisAlignment.start,
  202. children: [
  203. Row(
  204. children: [
  205. Pressable(
  206. onTap: () => Navigator.of(context).pop(),
  207. semanticLabel: 'Back',
  208. pressedScale: 0.92,
  209. child: Container(
  210. width: 46,
  211. height: 46,
  212. decoration: BoxDecoration(
  213. color: colors.surface,
  214. shape: BoxShape.circle,
  215. border: Border.all(color: colors.border),
  216. ),
  217. alignment: Alignment.center,
  218. child: AppIcon(
  219. 'assets/icons/arrow-left.svg',
  220. size: 20,
  221. color: colors.ink,
  222. ),
  223. ),
  224. ),
  225. const SizedBox(width: 16),
  226. Text(
  227. 'PIN',
  228. style: theme.textTheme.titleLarge?.copyWith(
  229. color: colors.inkMuted,
  230. ),
  231. ),
  232. const Spacer(),
  233. if (_stepCount > 1)
  234. Text(
  235. '${_step + 1} of $_stepCount',
  236. style: theme.textTheme.labelMedium?.copyWith(
  237. color: colors.inkFaint,
  238. ),
  239. ),
  240. ],
  241. ),
  242. Expanded(
  243. child: Center(
  244. child: SingleChildScrollView(
  245. child: ConstrainedBox(
  246. constraints: const BoxConstraints(maxWidth: 420),
  247. child: Column(
  248. mainAxisSize: MainAxisSize.min,
  249. children: [
  250. const BrandMark(size: 7, gap: 6),
  251. const SizedBox(height: 26),
  252. Text(
  253. _title,
  254. textAlign: TextAlign.center,
  255. style: theme.textTheme.headlineLarge,
  256. ),
  257. const SizedBox(height: 8),
  258. Text(
  259. _description,
  260. textAlign: TextAlign.center,
  261. style: theme.textTheme.bodyMedium?.copyWith(
  262. color: colors.inkMuted,
  263. ),
  264. ),
  265. const SizedBox(height: 28),
  266. AnimatedBuilder(
  267. animation: _shake,
  268. builder: (context, child) {
  269. final progress = _shake.value;
  270. final offset =
  271. math.sin(progress * math.pi * 6) *
  272. 9 *
  273. (1 - progress);
  274. return Transform.translate(
  275. offset: Offset(offset, 0),
  276. child: child,
  277. );
  278. },
  279. child: PinDots(
  280. length: 4,
  281. filled: _pin.length,
  282. error: error != null,
  283. ),
  284. ),
  285. const SizedBox(height: 14),
  286. SizedBox(
  287. height: 42,
  288. child: Text(
  289. error ?? 'Four digits',
  290. textAlign: TextAlign.center,
  291. style: theme.textTheme.bodySmall?.copyWith(
  292. color: error == null
  293. ? colors.inkFaint
  294. : colors.danger,
  295. ),
  296. ),
  297. ),
  298. const SizedBox(height: 6),
  299. PinKeypad(
  300. enabled: !_busy && !lock.isCoolingDown,
  301. onDigit: _onDigit,
  302. onBackspace: _onBackspace,
  303. ),
  304. ],
  305. ),
  306. ),
  307. ),
  308. ),
  309. ),
  310. ],
  311. ),
  312. ),
  313. ),
  314. ),
  315. );
  316. }
  317. }