391 lines
10 KiB
Dart
391 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../viewmodels/pin_lock_view_model.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../l10n/app_localizations_helpers.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;
|
|
final bool isAdmin;
|
|
final Future<bool> Function(String)? onVerify;
|
|
final Future<bool> Function(String)? onSet;
|
|
final String? Function()? errorMessage;
|
|
final bool showBack;
|
|
|
|
const PinEntryView({
|
|
super.key,
|
|
required this.mode,
|
|
this.onSuccess,
|
|
this.isAdmin = false,
|
|
this.onVerify,
|
|
this.onSet,
|
|
this.errorMessage,
|
|
this.showBack = false,
|
|
});
|
|
|
|
@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 _title(AppLocalizations l10n) {
|
|
if (widget.isAdmin) {
|
|
if (!_isCreateFlow) return l10n.enterAdminPin;
|
|
return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin;
|
|
}
|
|
|
|
if (!_isCreateFlow) return l10n.enterPin;
|
|
return _isConfirmStep ? l10n.confirmPin : l10n.createPin;
|
|
}
|
|
|
|
String? _subtitle(AppLocalizations l10n) {
|
|
if (!_isCreateFlow) return null;
|
|
|
|
if (widget.isAdmin) {
|
|
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.adminPinSubtitle;
|
|
}
|
|
|
|
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle;
|
|
}
|
|
|
|
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 l10n = AppLocalizations.of(context);
|
|
|
|
if (_isCreateFlow && !_isConfirmStep) {
|
|
final entered = _digits;
|
|
|
|
setState(() {
|
|
_firstEntry = entered;
|
|
_isConfirmStep = true;
|
|
_digits = '';
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (_isCreateFlow && _isConfirmStep) {
|
|
if (_digits != _firstEntry) {
|
|
setState(() {
|
|
_firstEntry = null;
|
|
_isConfirmStep = false;
|
|
});
|
|
_fail(l10n.pinsDidNotMatch);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isSubmitting = true);
|
|
final ok = await _setPin(_digits);
|
|
if (!mounted) return;
|
|
setState(() => _isSubmitting = false);
|
|
|
|
if (ok) {
|
|
widget.onSuccess?.call();
|
|
} else {
|
|
setState(() {
|
|
_firstEntry = null;
|
|
_isConfirmStep = false;
|
|
});
|
|
_fail(l10n.localizedError(_errorMessage()));
|
|
}
|
|
return;
|
|
}
|
|
|
|
setState(() => _isSubmitting = true);
|
|
final ok = await _verify(_digits);
|
|
if (!mounted) return;
|
|
setState(() => _isSubmitting = false);
|
|
|
|
if (ok) {
|
|
widget.onSuccess?.call();
|
|
} else {
|
|
_fail(l10n.localizedError(_errorMessage()));
|
|
}
|
|
}
|
|
|
|
Future<bool> _setPin(String pin) {
|
|
final callback = widget.onSet;
|
|
if (callback != null) return callback(pin);
|
|
|
|
return context.read<PinLockViewModel>().setPin(pin);
|
|
}
|
|
|
|
Future<bool> _verify(String pin) {
|
|
final callback = widget.onVerify;
|
|
if (callback != null) return callback(pin);
|
|
|
|
return context.read<PinLockViewModel>().verify(pin);
|
|
}
|
|
|
|
String? _errorMessage() {
|
|
final callback = widget.errorMessage;
|
|
if (callback != null) return callback();
|
|
|
|
return context.read<PinLockViewModel>().errorMessage;
|
|
}
|
|
|
|
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;
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
children: [
|
|
if (widget.showBack) ...[
|
|
const Spacer(flex: 1),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => {},
|
|
icon: const Icon(Icons.close),
|
|
tooltip: 'Back',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const Spacer(flex: 2),
|
|
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_title(l10n),
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
if (_subtitle(l10n) != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
_subtitle(l10n)!,
|
|
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: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (final digit in row) ...[
|
|
_KeypadButton(
|
|
label: digit,
|
|
onTap: enabled ? () => onDigit(digit) : null,
|
|
),
|
|
const SizedBox(width: 20),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const _KeypadButton(isSpacer: true, onTap: null),
|
|
const SizedBox(width: 20),
|
|
_KeypadButton(
|
|
label: '0',
|
|
onTap: enabled ? () => onDigit('0') : null,
|
|
),
|
|
const SizedBox(width: 20),
|
|
_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: 80, height: 80);
|
|
}
|
|
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
return SizedBox(
|
|
width: 80,
|
|
height: 80,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|