From 3ade961af3dee9b1bee6b830f1294f0005465b28 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:10:53 +0100 Subject: [PATCH] feat: 4-digit PIN input for profile switcher --- lib/screens/profile/pin_entry_dialog.dart | 492 +++++++++++++++++++--- 1 file changed, 436 insertions(+), 56 deletions(-) diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index fb8a3ed3..b0b8f0e2 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -1,10 +1,18 @@ -import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:flutter/services.dart'; -import '../../i18n/strings.g.dart'; +import 'dart:async'; -/// Dialog for entering a PIN to access a protected profile +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/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; +import '../../i18n/strings.g.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; @@ -16,20 +24,15 @@ class PinEntryDialog extends StatefulWidget { } class _PinEntryDialogState extends State with SingleTickerProviderStateMixin { - final _pinController = TextEditingController(); - final _focusNode = FocusNode(); - bool _obscureText = true; late AnimationController _shakeController; late Animation _shakeAnimation; + final _pinInputKey = GlobalKey<_TvPinInputState>(); @override void initState() { super.initState(); - // Setup shake animation _shakeController = AnimationController(duration: const Duration(milliseconds: 600), vsync: this); - - // Create a shake effect that oscillates _shakeAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1), TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1), @@ -38,37 +41,32 @@ class _PinEntryDialogState extends State with SingleTickerProvid TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1), ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut)); - // Auto-focus the PIN field when dialog opens - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - - // If there's an error message, trigger shake and clear field - if (widget.errorMessage != null) { - _pinController.clear(); + if (widget.errorMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { _shakeController.forward(from: 0); - } - }); + }); + } } @override void dispose() { - _pinController.dispose(); - _focusNode.dispose(); _shakeController.dispose(); super.dispose(); } - void _submit() { - final pin = _pinController.text.trim(); - if (pin.isEmpty) { - return; - } + void _submit(String pin) { Navigator.of(context).pop(pin); } + void _cancel() { + Navigator.of(context).pop(null); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isTV = PlatformDetector.isTV(); + final isMobile = PlatformDetector.isMobile(context) && !isTV; return AnimatedBuilder( animation: _shakeAnimation, @@ -87,44 +85,426 @@ class _PinEntryDialogState extends State with SingleTickerProvid mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( - controller: _pinController, - focusNode: _focusNode, - obscureText: _obscureText, - keyboardType: TextInputType.phone, - inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10)], - decoration: InputDecoration( - hintText: t.pinEntry.enterPin, - border: const OutlineInputBorder(), - errorText: widget.errorMessage, - errorMaxLines: 2, - suffixIcon: IconButton( - icon: AppIcon( - _obscureText ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, - fill: 1, - size: 20, - ), - onPressed: () { - setState(() { - _obscureText = !_obscureText; - }); - }, - tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin, - ), - ), - onSubmitted: (_) => _submit(), + _TvPinInput( + key: _pinInputKey, + onSubmit: _submit, + onCancel: _cancel, + hasError: widget.errorMessage != null, + isMobile: isMobile, + isTV: isTV, ), + if (widget.errorMessage != null) ...[ + const SizedBox(height: 12), + Text(widget.errorMessage!, style: TextStyle(color: theme.colorScheme.error, fontSize: 12), maxLines: 2), + ], ], ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), - FilledButton(onPressed: _submit, child: Text(t.common.submit)), + TextButton(onPressed: _cancel, child: Text(t.common.cancel)), + if (!isMobile) + FilledButton( + onPressed: () => _pinInputKey.currentState?._trySubmit(), + child: Text(t.common.submit), + ), ], ), ); } } +// --------------------------------------------------------------------------- +// _TvPinInput — unified 4-digit PIN input +// --------------------------------------------------------------------------- + +class _TvPinInput extends StatefulWidget { + final ValueChanged onSubmit; + final VoidCallback onCancel; + final bool hasError; + final bool isMobile; + final bool isTV; + + const _TvPinInput({ + super.key, + required this.onSubmit, + required this.onCancel, + required this.hasError, + required this.isMobile, + required this.isTV, + }); + + @override + State<_TvPinInput> createState() => _TvPinInputState(); +} + +class _TvPinInputState extends State<_TvPinInput> { + final List _digits = [null, null, null, null]; + int _activeIndex = 0; + bool _isFocused = false; + Timer? _repeatTimer; + + // Hidden text fields for mobile keyboard input + final List _mobileFocusNodes = List.generate(4, (_) => FocusNode()); + final List _mobileControllers = List.generate(4, (_) => TextEditingController()); + + // Main focus node for TV/desktop keyboard handling + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'PinInput'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.hasError) _reset(); + if (widget.isMobile) { + _mobileFocusNodes[0].requestFocus(); + } else { + _focusNode.requestFocus(); + } + }); + } + + @override + void dispose() { + _repeatTimer?.cancel(); + _focusNode.dispose(); + for (final node in _mobileFocusNodes) { + node.dispose(); + } + for (final controller in _mobileControllers) { + controller.dispose(); + } + super.dispose(); + } + + void _reset() { + setState(() { + for (int i = 0; i < 4; i++) { + _digits[i] = null; + if (widget.isMobile) _mobileControllers[i].clear(); + } + _activeIndex = 0; + }); + if (widget.isMobile) { + _mobileFocusNodes[0].requestFocus(); + } + } + + 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); + } + + // -- D-pad / keyboard handling (TV + desktop) -- + + void _incrementDigit() { + setState(() { + _digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10; + }); + } + + void _decrementDigit() { + setState(() { + final current = _digits[_activeIndex] ?? 0; + _digits[_activeIndex] = (current - 1 + 10) % 10; + }); + } + + void _startRepeat(VoidCallback action) { + action(); + _repeatTimer?.cancel(); + _repeatTimer = Timer(const Duration(milliseconds: 400), () { + _repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + action(); + }); + }); + } + + void _stopRepeat() { + _repeatTimer?.cancel(); + _repeatTimer = null; + } + + // Map digit keys (both main keyboard and numpad) + static final _digitKeyMap = { + 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 _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Back / escape → cancel + final backResult = handleBackKeyAction(event, widget.onCancel); + if (backResult != KeyEventResult.ignored) return backResult; + + if (event is KeyDownEvent) { + // Number key input (desktop only, TV uses d-pad) + if (!widget.isTV) { + final digit = _digitKeyMap[key]; + if (digit != null) { + setState(() { + _digits[_activeIndex] = digit; + if (_activeIndex < 3) _activeIndex++; + }); + return KeyEventResult.handled; + } + } + + // Backspace → clear and move left + if (key == LogicalKeyboardKey.backspace) { + setState(() { + if (_digits[_activeIndex] != null) { + _digits[_activeIndex] = null; + } else if (_activeIndex > 0) { + _activeIndex--; + _digits[_activeIndex] = null; + } + }); + return KeyEventResult.handled; + } + + // Up arrow → increment digit + if (key.isUpKey) { + _startRepeat(_incrementDigit); + return KeyEventResult.handled; + } + + // Down arrow → decrement digit + if (key.isDownKey) { + _startRepeat(_decrementDigit); + return KeyEventResult.handled; + } + + // Left arrow → move active index left + if (key.isLeftKey) { + if (_activeIndex > 0) { + setState(() => _activeIndex--); + } + return KeyEventResult.handled; + } + + // Right arrow → move active index right or advance to submit button + if (key.isRightKey) { + if (_activeIndex < 3) { + setState(() => _activeIndex++); + return KeyEventResult.handled; + } + // At rightmost digit, let focus move to submit button + return KeyEventResult.ignored; + } + + // Select / Enter → submit + if (key.isSelectKey) { + _trySubmit(); + return KeyEventResult.handled; + } + } + + if (event is KeyUpEvent) { + if (key.isUpKey || key.isDownKey) { + _stopRepeat(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + // -- Mobile input handling -- + + void _onMobileDigitChanged(int index, String value) { + if (value.isEmpty) { + // Backspace + setState(() => _digits[index] = null); + if (index > 0) { + _mobileFocusNodes[index - 1].requestFocus(); + setState(() => _activeIndex = index - 1); + } + return; + } + + // Take only the last character (handles paste/overwrite) + final digit = int.tryParse(value[value.length - 1]); + if (digit == null) { + _mobileControllers[index].clear(); + return; + } + + setState(() { + _digits[index] = digit; + _activeIndex = index; + _mobileControllers[index].text = digit.toString(); + _mobileControllers[index].selection = TextSelection.collapsed(offset: 1); + }); + + if (index < 3) { + _mobileFocusNodes[index + 1].requestFocus(); + setState(() => _activeIndex = index + 1); + } else { + // 4th digit entered → auto-submit + WidgetsBinding.instance.addPostFrameCallback((_) { + final pin = _getPin(); + if (pin != null) widget.onSubmit(pin); + }); + } + } + + @override + Widget build(BuildContext context) { + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final showArrows = (widget.isTV || isKeyboardMode) && !widget.isMobile; + + if (widget.isMobile) { + return _buildMobileLayout(context); + } + + return Focus( + focusNode: _focusNode, + autofocus: true, + onFocusChange: (hasFocus) { + setState(() => _isFocused = hasFocus); + if (!hasFocus) _stopRepeat(); + }, + onKeyEvent: _handleKeyEvent, + child: _buildDigitRow(context, showArrows: showArrows), + ); + } + + Widget _buildDigitRow(BuildContext context, {required bool showArrows}) { + 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: _isFocused && _activeIndex == i, + showArrows: showArrows && _isFocused && _activeIndex == i, + ), + ], + ], + ); + } + + Widget _buildMobileLayout(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (int i = 0; i < 4; i++) ...[ + if (i > 0) const SizedBox(width: 10), + SizedBox( + width: 52, + height: 60, + child: TextField( + controller: _mobileControllers[i], + focusNode: _mobileFocusNodes[i], + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 2, // allow overwrite + obscureText: true, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + decoration: InputDecoration( + counterText: '', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius)), + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (value) => _onMobileDigitChanged(i, value), + onSubmitted: (_) => _trySubmit(), + ), + ), + ], + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _DigitBox — single digit display box for TV/desktop +// --------------------------------------------------------------------------- + +class _DigitBox extends StatelessWidget { + final int? digit; + final bool isActive; + final bool showArrows; + + const _DigitBox({required this.digit, required this.isActive, required this.showArrows}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final focusColor = FocusTheme.getFocusBorderColor(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Up chevron + SizedBox( + height: 20, + child: showArrows + ? AppIcon(Symbols.keyboard_arrow_up_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant) + : null, + ), + // Digit box + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 48, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), + border: Border.all( + 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 ? digit.toString() : '–', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: digit != null ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + ), + ), + // Down chevron + SizedBox( + height: 20, + child: showArrows + ? AppIcon(Symbols.keyboard_arrow_down_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant) + : null, + ), + ], + ); + } +} + /// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled Future showPinEntryDialog(BuildContext context, String userName, {String? errorMessage}) { return showDialog(