lock_screen.dart 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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: AnimatedSwitcher(
  141. duration: const Duration(milliseconds: 160),
  142. child: Text(
  143. error ?? 'Enter your four-digit PIN',
  144. key: ValueKey(error ?? 'hint'),
  145. textAlign: TextAlign.center,
  146. style: theme.textTheme.bodySmall?.copyWith(
  147. color: error == null
  148. ? colors.inkFaint
  149. : colors.danger,
  150. ),
  151. ),
  152. ),
  153. ),
  154. const SizedBox(height: 12),
  155. PinKeypad(
  156. enabled: !_busy && !lock.isCoolingDown,
  157. onDigit: _onDigit,
  158. onBackspace: _onBackspace,
  159. ),
  160. ],
  161. ),
  162. ),
  163. ),
  164. ),
  165. ),
  166. ),
  167. ),
  168. );
  169. }
  170. }