686 lines
20 KiB
Dart
686 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:material_symbols_icons/symbols.dart';
|
||
|
||
import '../../focus/dpad_navigator.dart';
|
||
import '../../focus/focus_theme.dart';
|
||
import '../../focus/key_event_utils.dart';
|
||
import '../../focus/focusable_button.dart';
|
||
import '../../i18n/strings.g.dart';
|
||
import '../../mixins/controller_disposer_mixin.dart';
|
||
import '../../utils/platform_detector.dart';
|
||
import '../../widgets/app_icon.dart';
|
||
|
||
/// Dialog for entering a 4-digit PIN to access a protected profile.
|
||
class PinEntryDialog extends StatefulWidget {
|
||
final String userName;
|
||
final String? errorMessage;
|
||
|
||
const PinEntryDialog({super.key, required this.userName, this.errorMessage});
|
||
|
||
@override
|
||
State<PinEntryDialog> createState() => _PinEntryDialogState();
|
||
}
|
||
|
||
class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProviderStateMixin {
|
||
late AnimationController _shakeController;
|
||
late Animation<double> _shakeAnimation;
|
||
final _pinInputKey = GlobalKey<_TvPinInputState>();
|
||
final _cancelFocusNode = FocusNode(debugLabel: 'PinCancelButton');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
_shakeController = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
|
||
_shakeAnimation = TweenSequence<double>([
|
||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1),
|
||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1),
|
||
TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1),
|
||
TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1),
|
||
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut));
|
||
|
||
if (widget.errorMessage != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_shakeController.forward(from: 0);
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_shakeController.dispose();
|
||
_cancelFocusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit(String pin) {
|
||
Navigator.of(context).pop(pin);
|
||
}
|
||
|
||
void _cancel() {
|
||
Navigator.of(context).pop(null);
|
||
}
|
||
|
||
void _focusPinInput() {
|
||
_pinInputKey.currentState?._requestInputFocus();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isTV = PlatformDetector.isTV();
|
||
final isMobile = PlatformDetector.isMobile(context) && !isTV;
|
||
|
||
return AnimatedBuilder(
|
||
animation: _shakeAnimation,
|
||
builder: (context, child) {
|
||
return Transform.translate(offset: Offset(_shakeAnimation.value, 0), child: child);
|
||
},
|
||
child: isMobile ? _buildMobileDialog(theme) : _buildTvDialog(theme),
|
||
);
|
||
}
|
||
|
||
Widget _buildMobileDialog(ThemeData theme) {
|
||
return AlertDialog(
|
||
title: _buildTitle(theme),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_TvPinInput(
|
||
key: _pinInputKey,
|
||
onSubmit: _submit,
|
||
onCancel: _cancel,
|
||
hasError: widget.errorMessage != null,
|
||
isMobile: true,
|
||
),
|
||
if (widget.errorMessage != null) ...[
|
||
const SizedBox(height: 12),
|
||
Text(widget.errorMessage!, style: TextStyle(color: theme.colorScheme.error, fontSize: 12), maxLines: 2),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
FocusableButton(
|
||
focusNode: _cancelFocusNode,
|
||
onPressed: _cancel,
|
||
onNavigateUp: _focusPinInput,
|
||
onBack: _cancel,
|
||
child: TextButton(onPressed: _cancel, child: Text(t.common.cancel)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildTvDialog(ThemeData theme) {
|
||
final colorScheme = theme.colorScheme;
|
||
|
||
return Dialog(
|
||
insetPadding: const EdgeInsets.symmetric(horizontal: 56, vertical: 32),
|
||
backgroundColor: Colors.transparent,
|
||
child: Container(
|
||
constraints: const BoxConstraints(maxWidth: 320),
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surface.withValues(alpha: 0.96),
|
||
borderRadius: BorderRadius.circular(28),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildTitle(theme),
|
||
const SizedBox(height: 12),
|
||
_TvPinInput(
|
||
key: _pinInputKey,
|
||
onSubmit: _submit,
|
||
onCancel: _cancel,
|
||
hasError: widget.errorMessage != null,
|
||
isMobile: false,
|
||
),
|
||
if (widget.errorMessage != null) ...[
|
||
const SizedBox(height: 12),
|
||
Text(widget.errorMessage!, style: TextStyle(color: colorScheme.error, fontSize: 12), maxLines: 2),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTitle(ThemeData theme) {
|
||
return Row(
|
||
children: [
|
||
AppIcon(Symbols.lock_outline_rounded, fill: 1, size: 24, color: theme.colorScheme.primary),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Text(widget.userName, overflow: TextOverflow.ellipsis)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TvPinInput extends StatefulWidget {
|
||
final ValueChanged<String> onSubmit;
|
||
final VoidCallback onCancel;
|
||
final bool hasError;
|
||
final bool isMobile;
|
||
|
||
const _TvPinInput({
|
||
super.key,
|
||
required this.onSubmit,
|
||
required this.onCancel,
|
||
required this.hasError,
|
||
required this.isMobile,
|
||
});
|
||
|
||
@override
|
||
State<_TvPinInput> createState() => _TvPinInputState();
|
||
}
|
||
|
||
class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin {
|
||
static const _keySize = 60.0;
|
||
static const _keyGap = 6.0;
|
||
static const _rowGap = 6.0;
|
||
static const _keypadColumns = 3;
|
||
static const _rows = <List<_PinKey>>[
|
||
[_PinKey.digit(1), _PinKey.digit(2), _PinKey.digit(3)],
|
||
[_PinKey.digit(4), _PinKey.digit(5), _PinKey.digit(6)],
|
||
[_PinKey.digit(7), _PinKey.digit(8), _PinKey.digit(9)],
|
||
[_PinKey.close(), _PinKey.digit(0), _PinKey.backspace()],
|
||
];
|
||
|
||
final List<int?> _digits = [null, null, null, null];
|
||
int _activeIndex = 0;
|
||
int _row = 0;
|
||
int _column = 0;
|
||
|
||
// Keep a single mobile text input client so iOS does not hide/show the
|
||
// software keyboard while advancing between visual PIN boxes.
|
||
final FocusNode _mobileFocusNode = FocusNode(debugLabel: 'PinInputMobile');
|
||
late final TextEditingController _mobileController = createTextEditingController();
|
||
final FocusNode _keypadFocusNode = FocusNode(debugLabel: 'PinKeypad');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_mobileFocusNode.addListener(_handleMobileFocusChanged);
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
if (widget.hasError) _reset();
|
||
if (widget.isMobile) {
|
||
_requestMobileKeyboardFocus();
|
||
} else {
|
||
_requestKeypadFocus();
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_keypadFocusNode.dispose();
|
||
_mobileFocusNode.removeListener(_handleMobileFocusChanged);
|
||
_mobileFocusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _handleMobileFocusChanged() {
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
void _reset() {
|
||
setState(() {
|
||
for (int i = 0; i < 4; i++) {
|
||
_digits[i] = null;
|
||
}
|
||
if (widget.isMobile) _mobileController.clear();
|
||
_activeIndex = 0;
|
||
});
|
||
if (widget.isMobile) {
|
||
_requestMobileKeyboardFocus();
|
||
} else {
|
||
_row = 0;
|
||
_column = 0;
|
||
_requestKeypadFocus();
|
||
}
|
||
}
|
||
|
||
int get _pinLength {
|
||
for (int i = 0; i < _digits.length; i++) {
|
||
if (_digits[i] == null) return i;
|
||
}
|
||
return _digits.length;
|
||
}
|
||
|
||
String? _getPin() {
|
||
if (_digits.any((d) => d == null)) return null;
|
||
return _digits.map((d) => d.toString()).join();
|
||
}
|
||
|
||
void _trySubmit() {
|
||
final pin = _getPin();
|
||
if (pin != null) widget.onSubmit(pin);
|
||
}
|
||
|
||
void _requestInputFocus() {
|
||
if (widget.isMobile) {
|
||
_requestMobileKeyboardFocus();
|
||
} else {
|
||
_requestKeypadFocus();
|
||
}
|
||
}
|
||
|
||
void _requestKeypadFocus() {
|
||
if (widget.isMobile) return;
|
||
_keypadFocusNode.requestFocus();
|
||
}
|
||
|
||
void _requestMobileKeyboardFocus() {
|
||
_mobileFocusNode.requestFocus();
|
||
final offset = _mobileController.text.length;
|
||
_mobileController.selection = TextSelection.collapsed(offset: offset);
|
||
}
|
||
|
||
void _setPinFromString(String pin) {
|
||
setState(() {
|
||
for (int i = 0; i < 4; i++) {
|
||
_digits[i] = i < pin.length ? int.parse(pin[i]) : null;
|
||
}
|
||
_activeIndex = pin.length >= 4 ? 3 : pin.length;
|
||
});
|
||
|
||
if (pin.length == 4) _submitWhenCompleteAfterFrame();
|
||
}
|
||
|
||
void _appendDigit(int digit) {
|
||
final length = _pinLength;
|
||
if (length >= _digits.length) return;
|
||
|
||
setState(() {
|
||
_digits[length] = digit;
|
||
final nextLength = length + 1;
|
||
_activeIndex = nextLength >= _digits.length ? _digits.length - 1 : nextLength;
|
||
});
|
||
|
||
if (length + 1 == _digits.length) _submitWhenCompleteAfterFrame();
|
||
}
|
||
|
||
void _deleteLastDigit() {
|
||
final length = _pinLength;
|
||
if (length == 0) return;
|
||
|
||
setState(() {
|
||
_digits[length - 1] = null;
|
||
_activeIndex = length - 1;
|
||
});
|
||
}
|
||
|
||
void _submitWhenCompleteAfterFrame() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
final pin = _getPin();
|
||
if (pin != null) widget.onSubmit(pin);
|
||
});
|
||
}
|
||
|
||
// Map digit keys (both main keyboard and numpad)
|
||
static final _digitKeyMap = <LogicalKeyboardKey, int>{
|
||
LogicalKeyboardKey.digit0: 0,
|
||
LogicalKeyboardKey.digit1: 1,
|
||
LogicalKeyboardKey.digit2: 2,
|
||
LogicalKeyboardKey.digit3: 3,
|
||
LogicalKeyboardKey.digit4: 4,
|
||
LogicalKeyboardKey.digit5: 5,
|
||
LogicalKeyboardKey.digit6: 6,
|
||
LogicalKeyboardKey.digit7: 7,
|
||
LogicalKeyboardKey.digit8: 8,
|
||
LogicalKeyboardKey.digit9: 9,
|
||
LogicalKeyboardKey.numpad0: 0,
|
||
LogicalKeyboardKey.numpad1: 1,
|
||
LogicalKeyboardKey.numpad2: 2,
|
||
LogicalKeyboardKey.numpad3: 3,
|
||
LogicalKeyboardKey.numpad4: 4,
|
||
LogicalKeyboardKey.numpad5: 5,
|
||
LogicalKeyboardKey.numpad6: 6,
|
||
LogicalKeyboardKey.numpad7: 7,
|
||
LogicalKeyboardKey.numpad8: 8,
|
||
LogicalKeyboardKey.numpad9: 9,
|
||
};
|
||
|
||
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
|
||
final key = event.logicalKey;
|
||
|
||
final backResult = handleBackKeyAction(event, widget.onCancel);
|
||
if (backResult != KeyEventResult.ignored) return backResult;
|
||
|
||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||
if (key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.delete) {
|
||
_deleteLastDigit();
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
if (event.isPhysicalKeyboardEnter) {
|
||
_trySubmit();
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
if (event.isTvSelectEvent) {
|
||
_activate(_rows[_row][_column]);
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
if (key.isUpKey) {
|
||
_moveVertical(-1);
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (key.isDownKey) {
|
||
_moveVertical(1);
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (key.isLeftKey) {
|
||
_moveHorizontal(-1);
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (key.isRightKey) {
|
||
_moveHorizontal(1);
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
if (event is! KeyDownEvent) return KeyEventResult.handled;
|
||
|
||
final digit = _digitKeyMap[key];
|
||
if (digit != null) {
|
||
_appendDigit(digit);
|
||
return KeyEventResult.handled;
|
||
}
|
||
}
|
||
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
void _onMobilePinChanged(String value) {
|
||
final digitsOnly = value.replaceAll(RegExp(r'\D'), '');
|
||
final pin = digitsOnly.length > 4 ? digitsOnly.substring(0, 4) : digitsOnly;
|
||
if (pin != value) {
|
||
_mobileController.value = TextEditingValue(
|
||
text: pin,
|
||
selection: TextSelection.collapsed(offset: pin.length),
|
||
);
|
||
}
|
||
|
||
_setPinFromString(pin);
|
||
}
|
||
|
||
void _activate(_PinKey key) {
|
||
switch (key.type) {
|
||
case _PinKeyType.digit:
|
||
_appendDigit(key.digit!);
|
||
return;
|
||
case _PinKeyType.backspace:
|
||
_deleteLastDigit();
|
||
return;
|
||
case _PinKeyType.close:
|
||
widget.onCancel();
|
||
return;
|
||
}
|
||
}
|
||
|
||
void _moveHorizontal(int delta) {
|
||
setState(() {
|
||
_column = (_column + delta).clamp(0, _keypadColumns - 1).toInt();
|
||
});
|
||
}
|
||
|
||
void _moveVertical(int delta) {
|
||
setState(() {
|
||
_row = (_row + delta).clamp(0, _rows.length - 1).toInt();
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (widget.isMobile) {
|
||
return _buildMobileLayout(context);
|
||
}
|
||
|
||
return Focus(focusNode: _keypadFocusNode, onKeyEvent: _handleKey, child: _buildKeypadLayout(context));
|
||
}
|
||
|
||
Widget _buildKeypadLayout(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [_buildDigitRow(context, obscureDigits: true), const SizedBox(height: 18), _buildKeypad(context)],
|
||
);
|
||
}
|
||
|
||
Widget _buildKeypad(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (int row = 0; row < _rows.length; row++) ...[
|
||
if (row > 0) const SizedBox(height: _rowGap),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (int col = 0; col < _keypadColumns; col++) ...[
|
||
if (col > 0) const SizedBox(width: _keyGap),
|
||
_buildKey(context, _rows[row][col], row, col),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildKey(BuildContext context, _PinKey key, int row, int column) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
final selected = row == _row && column == _column;
|
||
final background = selected ? colorScheme.primary : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88);
|
||
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
setState(() {
|
||
_row = row;
|
||
_column = column;
|
||
});
|
||
_activate(key);
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 120),
|
||
width: _keySize,
|
||
height: _keySize,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: _buildKeyContent(context, key, foreground),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildKeyContent(BuildContext context, _PinKey key, Color foreground) {
|
||
final icon = key.icon;
|
||
if (icon != null) return Icon(icon, color: foreground, size: 30);
|
||
|
||
return FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
key.label,
|
||
maxLines: 1,
|
||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: FontWeight.w800),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDigitRow(BuildContext _, {bool obscureDigits = false}) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
for (int i = 0; i < 4; i++) ...[
|
||
if (i > 0) const SizedBox(width: 10),
|
||
_DigitBox(
|
||
digit: _digits[i],
|
||
isActive: (widget.isMobile ? _mobileFocusNode.hasFocus : true) && _activeIndex == i,
|
||
obscureDigit: obscureDigits,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildMobileLayout(BuildContext context) {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _requestMobileKeyboardFocus,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 222,
|
||
height: 56,
|
||
child: IgnorePointer(
|
||
child: TextField(
|
||
controller: _mobileController,
|
||
focusNode: _mobileFocusNode,
|
||
keyboardType: TextInputType.number,
|
||
textInputAction: TextInputAction.done,
|
||
textAlign: TextAlign.center,
|
||
maxLength: 4,
|
||
obscureText: true,
|
||
autocorrect: false,
|
||
enableSuggestions: false,
|
||
enableInteractiveSelection: false,
|
||
showCursor: false,
|
||
cursorColor: Colors.transparent,
|
||
style: const TextStyle(color: Colors.transparent, fontSize: 1, height: 1),
|
||
decoration: const InputDecoration(
|
||
counterText: '',
|
||
border: InputBorder.none,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4)],
|
||
onChanged: _onMobilePinChanged,
|
||
onSubmitted: (_) => _trySubmit(),
|
||
),
|
||
),
|
||
),
|
||
_buildDigitRow(context, obscureDigits: true),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DigitBox extends StatelessWidget {
|
||
final int? digit;
|
||
final bool isActive;
|
||
final bool obscureDigit;
|
||
|
||
const _DigitBox({required this.digit, required this.isActive, this.obscureDigit = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final focusColor = FocusTheme.getFocusBorderColor(context);
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
width: 48,
|
||
height: 56,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
|
||
border: Border.fromBorderSide(
|
||
BorderSide(
|
||
color: isActive ? focusColor : theme.colorScheme.outlineVariant,
|
||
width: isActive ? FocusTheme.focusBorderWidth : 1.5,
|
||
),
|
||
),
|
||
color: isActive ? focusColor.withValues(alpha: 0.08) : Colors.transparent,
|
||
),
|
||
child: Text(
|
||
digit != null ? (obscureDigit || !isActive ? '•' : digit.toString()) : '–',
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
color: digit != null
|
||
? theme.colorScheme.onSurface
|
||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _PinKeyType { digit, backspace, close }
|
||
|
||
class _PinKey {
|
||
final _PinKeyType type;
|
||
final int? digit;
|
||
|
||
const _PinKey.digit(this.digit) : type = _PinKeyType.digit;
|
||
const _PinKey.backspace() : type = _PinKeyType.backspace, digit = null;
|
||
const _PinKey.close() : type = _PinKeyType.close, digit = null;
|
||
|
||
String get label {
|
||
switch (type) {
|
||
case _PinKeyType.digit:
|
||
return digit.toString();
|
||
case _PinKeyType.backspace:
|
||
return 'Del';
|
||
case _PinKeyType.close:
|
||
return t.common.cancel;
|
||
}
|
||
}
|
||
|
||
IconData? get icon {
|
||
switch (type) {
|
||
case _PinKeyType.digit:
|
||
return null;
|
||
case _PinKeyType.backspace:
|
||
return Icons.backspace_outlined;
|
||
case _PinKeyType.close:
|
||
return Icons.close_rounded;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled
|
||
Future<String?> showPinEntryDialog(BuildContext context, String userName, {String? errorMessage}) {
|
||
return showDialog<String>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => PinEntryDialog(userName: userName, errorMessage: errorMessage),
|
||
);
|
||
}
|
||
|
||
/// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null
|
||
/// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch]
|
||
/// (or no-op if not provided) and returns null — the helper keeps the UX
|
||
/// in one place so multiple call sites don't drift.
|
||
Future<String?> captureAndConfirmPin(
|
||
BuildContext context, {
|
||
String setLabel = 'Set PIN',
|
||
String confirmLabel = 'Confirm PIN',
|
||
void Function(BuildContext)? onMismatch,
|
||
}) async {
|
||
final pin = await showPinEntryDialog(context, setLabel);
|
||
if (pin == null || !context.mounted) return null;
|
||
final confirm = await showPinEntryDialog(context, confirmLabel);
|
||
if (confirm == null || !context.mounted) return null;
|
||
if (pin == confirm) return pin;
|
||
onMismatch?.call(context);
|
||
return null;
|
||
}
|