Files
kooltab/lib/views/pin_lock_view.dart
T
2026-07-27 21:32:03 +02:00

331 lines
8.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/pin_lock_view_model.dart';
enum PinEntryMode { unlock, create }
const _pinLength = 4;
/// Full-screen PIN entry. In [PinEntryMode.unlock] it verifies a PIN
/// against the one already saved. In [PinEntryMode.create] it walks the
/// person through entering a new PIN twice before saving it.
class PinEntryView extends StatefulWidget {
final PinEntryMode mode;
final VoidCallback? onSuccess;
const PinEntryView({
super.key,
required this.mode,
this.onSuccess,
});
@override
State<PinEntryView> createState() => _PinEntryViewState();
}
class _PinEntryViewState extends State<PinEntryView> {
String _digits = '';
String? _firstEntry;
bool _isConfirmStep = false;
bool _isSubmitting = false;
String? _localError;
bool _shake = false;
bool get _isCreateFlow => widget.mode == PinEntryMode.create;
String get _title {
if (!_isCreateFlow) return 'Enter PIN';
return _isConfirmStep ? 'Confirm PIN' : 'Create a PIN';
}
String? get _subtitle {
if (!_isCreateFlow) return null;
return _isConfirmStep
? 'Enter the same PIN again'
: 'Youʼll use this to unlock the app';
}
void _onDigitPressed(String digit) {
if (_isSubmitting || _digits.length >= _pinLength) return;
setState(() {
_digits += digit;
_localError = null;
});
if (_digits.length == _pinLength) {
_handleComplete();
}
}
void _onBackspacePressed() {
if (_isSubmitting || _digits.isEmpty) return;
setState(() {
_digits = _digits.substring(0, _digits.length - 1);
});
}
Future<void> _handleComplete() async {
final viewModel = context.read<PinLockViewModel>();
if (_isCreateFlow && !_isConfirmStep) {
// First entry of a new PIN — stash it, then ask for confirmation.
final entered = _digits;
setState(() {
_firstEntry = entered;
_isConfirmStep = true;
_digits = '';
});
return;
}
if (_isCreateFlow && _isConfirmStep) {
if (_digits != _firstEntry) {
setState(() {
_firstEntry = null;
_isConfirmStep = false;
});
_fail('PINs didnʼt match. Try again.');
return;
}
setState(() => _isSubmitting = true);
final ok = await viewModel.setPin(_digits);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (ok) {
widget.onSuccess?.call();
} else {
setState(() {
_firstEntry = null;
_isConfirmStep = false;
});
_fail(viewModel.errorMessage ?? 'Something went wrong.');
}
return;
}
// Unlock flow.
setState(() => _isSubmitting = true);
final ok = await viewModel.verify(_digits);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (ok) {
widget.onSuccess?.call();
} else {
_fail(viewModel.errorMessage ?? 'Incorrect PIN.');
}
}
void _fail(String message) {
setState(() {
_localError = message;
_digits = '';
_shake = true;
});
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) setState(() => _shake = false);
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Spacer(flex: 2),
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
const SizedBox(height: 16),
Text(_title, style: Theme.of(context).textTheme.headlineMedium),
if (_subtitle != null) ...[
const SizedBox(height: 6),
Text(
_subtitle!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
const SizedBox(height: 28),
AnimatedContainer(
duration: const Duration(milliseconds: 60),
transform: Matrix4.translationValues(_shake ? 8 : 0, 0, 0),
child: _PinDots(filled: _digits.length, total: _pinLength),
),
const SizedBox(height: 16),
SizedBox(
height: 20,
child: _localError != null
? Text(
_localError!,
style: TextStyle(color: scheme.error, fontSize: 13),
)
: const SizedBox.shrink(),
),
const Spacer(flex: 2),
_PinKeypad(
enabled: !_isSubmitting,
onDigit: _onDigitPressed,
onBackspace: _onBackspacePressed,
),
const Spacer(),
],
),
),
),
);
}
}
class _PinDots extends StatelessWidget {
final int filled;
final int total;
const _PinDots({required this.filled, required this.total});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(total, (index) {
final isFilled = index < filled;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isFilled ? scheme.primary : Colors.transparent,
border: Border.all(
color: isFilled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.3),
width: 1.4,
),
),
);
}),
);
}
}
class _PinKeypad extends StatelessWidget {
final bool enabled;
final ValueChanged<String> onDigit;
final VoidCallback onBackspace;
const _PinKeypad({
required this.enabled,
required this.onDigit,
required this.onBackspace,
});
static const _rows = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
];
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final row in _rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
spacing: 16,
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final digit in row)
_KeypadButton(
label: digit,
onTap: enabled ? () => onDigit(digit) : null,
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
spacing: 16,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _KeypadButton(isSpacer: true, onTap: null),
_KeypadButton(
label: '0',
onTap: enabled ? () => onDigit('0') : null,
),
_KeypadButton(
icon: Icons.backspace_outlined,
onTap: enabled ? onBackspace : null,
),
],
),
),
],
);
}
}
class _KeypadButton extends StatelessWidget {
final String? label;
final IconData? icon;
final VoidCallback? onTap;
final bool isSpacer;
const _KeypadButton({
this.label,
this.icon,
required this.onTap,
this.isSpacer = false,
});
@override
Widget build(BuildContext context) {
if (isSpacer) {
return const SizedBox(width: 72, height: 72);
}
final scheme = Theme.of(context).colorScheme;
return SizedBox(
width: 72,
height: 72,
child: Material(
color: scheme.onSurface.withValues(alpha: 0.04),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Center(
child: icon != null
? Icon(icon, color: scheme.onSurface.withValues(alpha: 0.8))
: Text(
label!,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: onTap == null
? scheme.onSurface.withValues(alpha: 0.3)
: scheme.onSurface,
),
),
),
),
),
);
}
}