lock_screen.dart 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import 'dart:async';
  2. import 'dart:math' as math;
  3. import 'package:flutter/material.dart';
  4. import 'package:provider/provider.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import 'lock_controller.dart';
  7. import 'pin_pad.dart';
  8. /// Full-screen gate shown only when the launcher is cold-started with a PIN
  9. /// configured. It never re-locks a running session.
  10. class LockScreen extends StatefulWidget {
  11. const LockScreen({super.key});
  12. @override
  13. State<LockScreen> createState() => _LockScreenState();
  14. }
  15. class _LockScreenState extends State<LockScreen>
  16. with SingleTickerProviderStateMixin {
  17. String _pin = '';
  18. bool _busy = false;
  19. Timer? _tick;
  20. late final AnimationController _shake = AnimationController(
  21. vsync: this,
  22. duration: const Duration(milliseconds: 380),
  23. );
  24. @override
  25. void initState() {
  26. super.initState();
  27. _tick = Timer.periodic(const Duration(seconds: 1), (_) {
  28. if (mounted) setState(() {});
  29. });
  30. }
  31. @override
  32. void dispose() {
  33. _tick?.cancel();
  34. _shake.dispose();
  35. super.dispose();
  36. }
  37. void _onDigit(String digit) {
  38. if (_busy || _pin.length >= 4) return;
  39. final lock = context.read<LockController>();
  40. if (lock.isCoolingDown) return;
  41. setState(() => _pin += digit);
  42. if (_pin.length == 4) {
  43. setState(() => _busy = true);
  44. Future<void>.delayed(const Duration(milliseconds: 110), _submit);
  45. }
  46. }
  47. void _onBackspace() {
  48. if (_busy || _pin.isEmpty) return;
  49. setState(() => _pin = _pin.substring(0, _pin.length - 1));
  50. }
  51. Future<void> _submit() async {
  52. if (!mounted) return;
  53. final lock = context.read<LockController>();
  54. final success = await lock.unlock(_pin);
  55. if (!mounted) return;
  56. if (success) {
  57. return; // The root widget removes this screen.
  58. }
  59. setState(() {
  60. _pin = '';
  61. _busy = false;
  62. });
  63. _shake.forward(from: 0);
  64. }
  65. @override
  66. Widget build(BuildContext context) {
  67. final colors = context.colors;
  68. final theme = Theme.of(context);
  69. final lock = context.watch<LockController>();
  70. final error = lock.isCoolingDown
  71. ? 'Too many attempts. Try again in ${lock.cooldownSecondsRemaining}s.'
  72. : lock.errorMessage;
  73. return PopScope(
  74. canPop: false,
  75. child: Scaffold(
  76. backgroundColor: colors.canvas,
  77. body: Container(
  78. decoration: BoxDecoration(
  79. gradient: LinearGradient(
  80. begin: Alignment.topLeft,
  81. end: Alignment.bottomRight,
  82. colors: Theme.of(context).brightness == Brightness.dark
  83. ? [colors.canvas, colors.canvasDeep]
  84. : [colors.canvas, const Color(0xFFF7F5EF)],
  85. ),
  86. ),
  87. child: SafeArea(
  88. child: Center(
  89. child: SingleChildScrollView(
  90. padding: const EdgeInsets.symmetric(
  91. horizontal: 32,
  92. vertical: 28,
  93. ),
  94. child: ConstrainedBox(
  95. constraints: const BoxConstraints(maxWidth: 420),
  96. child: Column(
  97. mainAxisSize: MainAxisSize.min,
  98. children: [
  99. const BrandMark(size: 7, gap: 6),
  100. const SizedBox(height: 16),
  101. Text(
  102. 'CarDash',
  103. style: theme.textTheme.titleLarge?.copyWith(
  104. letterSpacing: 0.2,
  105. color: colors.inkMuted,
  106. ),
  107. ),
  108. const SizedBox(height: 34),
  109. Text('Enter PIN', style: theme.textTheme.headlineLarge),
  110. const SizedBox(height: 8),
  111. Text(
  112. 'CarDash is locked.',
  113. style: theme.textTheme.bodyMedium?.copyWith(
  114. color: colors.inkMuted,
  115. ),
  116. ),
  117. const SizedBox(height: 30),
  118. AnimatedBuilder(
  119. animation: _shake,
  120. builder: (context, child) {
  121. final progress = _shake.value;
  122. final offset =
  123. math.sin(progress * math.pi * 6) *
  124. 9 *
  125. (1 - progress);
  126. return Transform.translate(
  127. offset: Offset(offset, 0),
  128. child: child,
  129. );
  130. },
  131. child: PinDots(
  132. length: 4,
  133. filled: _pin.length,
  134. error: error != null,
  135. ),
  136. ),
  137. const SizedBox(height: 14),
  138. SizedBox(
  139. height: 42,
  140. child: _busy
  141. ? Row(
  142. mainAxisAlignment: MainAxisAlignment.center,
  143. children: [
  144. SizedBox(
  145. width: 16,
  146. height: 16,
  147. child: CircularProgressIndicator(
  148. strokeWidth: 2,
  149. color: colors.accent,
  150. ),
  151. ),
  152. const SizedBox(width: 9),
  153. Text(
  154. 'Checking…',
  155. style: theme.textTheme.bodySmall?.copyWith(
  156. color: colors.inkMuted,
  157. ),
  158. ),
  159. ],
  160. )
  161. : AnimatedSwitcher(
  162. duration: const Duration(milliseconds: 160),
  163. child: Text(
  164. error ?? 'Enter your four-digit PIN',
  165. key: ValueKey(error ?? 'hint'),
  166. textAlign: TextAlign.center,
  167. style: theme.textTheme.bodySmall?.copyWith(
  168. color: error == null
  169. ? colors.inkFaint
  170. : colors.danger,
  171. ),
  172. ),
  173. ),
  174. ),
  175. const SizedBox(height: 12),
  176. PinKeypad(
  177. enabled: !_busy && !lock.isCoolingDown,
  178. onDigit: _onDigit,
  179. onBackspace: _onBackspace,
  180. ),
  181. ],
  182. ),
  183. ),
  184. ),
  185. ),
  186. ),
  187. ),
  188. ),
  189. );
  190. }
  191. }