Files
plezy/lib/screens/profile/pin_entry_dialog.dart
T

686 lines
20 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: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;
}