import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/services/pin_service.dart'; /// Enforces the app PIN. /// /// Deliberately simple: the PIN is checked only while the launcher process is /// starting. There is no re-lock on resume or screen-off. Setting or changing /// the PIN locks once immediately so the new code is confirmed in use. class LockController extends ChangeNotifier { LockController(this._pinService); final PinService _pinService; bool _initialized = false; bool _pinSet = false; bool _locked = false; String? _errorMessage; int _failedAttempts = 0; DateTime? _cooldownUntil; Timer? _cooldownTimer; bool _disposed = false; bool get initialized => _initialized; bool get pinSet => _pinSet; bool get isLocked => _locked; String? get errorMessage => _errorMessage; bool get isCoolingDown { final until = _cooldownUntil; return until != null && until.isAfter(DateTime.now()); } int get cooldownSecondsRemaining { final until = _cooldownUntil; if (until == null) return 0; final remaining = until.difference(DateTime.now()).inSeconds; return remaining < 0 ? 0 : remaining; } Future initialize() async { try { _pinSet = await _pinService.isPinSet(); _locked = _pinSet; } catch (_) { // If secure storage cannot be read, do not fabricate a PIN. The security // section exposes the error so the issue is visible rather than silent. _pinSet = false; _locked = false; _errorMessage = 'Secure storage is unavailable on this device.'; } _initialized = true; _notify(); } Future unlock(String pin) async { if (!_pinSet) { _locked = false; _notify(); return true; } if (isCoolingDown) { _errorMessage = 'Too many attempts. Try again in ${cooldownSecondsRemaining}s.'; _notify(); return false; } _errorMessage = null; bool valid; try { valid = await _pinService.verify(pin); } catch (_) { _errorMessage = 'Could not verify the PIN. Try again.'; _notify(); return false; } if (valid) { _locked = false; _failedAttempts = 0; _cooldownUntil = null; _errorMessage = null; _cooldownTimer?.cancel(); _notify(); return true; } _recordFailure(); return false; } Future setPin(String pin) async { try { await _pinService.setPin(pin); } catch (_) { return 'Could not save the PIN.'; } _pinSet = true; // The PIN is only requested on the next cold start by design. Setting or // changing it does not lock the current session. _failedAttempts = 0; _cooldownUntil = null; _cooldownTimer?.cancel(); _errorMessage = null; _notify(); return null; } Future changePin({ required String currentPin, required String newPin, }) async { final verified = await _verifyCurrent(currentPin); if (!verified) { return _errorMessage ?? 'Current PIN is incorrect.'; } return setPin(newPin); } Future removePin(String currentPin) async { final verified = await _verifyCurrent(currentPin); if (!verified) { return _errorMessage ?? 'Current PIN is incorrect.'; } try { await _pinService.clearPin(); } catch (_) { return 'Could not remove the PIN.'; } _pinSet = false; _locked = false; _errorMessage = null; _failedAttempts = 0; _cooldownUntil = null; _cooldownTimer?.cancel(); _notify(); return null; } /// Verifies the current PIN without changing the lock state. Used by the /// change/remove flows in Settings. Future verifyPin(String pin) => _verifyCurrent(pin); Future _verifyCurrent(String pin) async { if (isCoolingDown) { _errorMessage = 'Too many attempts. Try again in ${cooldownSecondsRemaining}s.'; _notify(); return false; } try { final valid = await _pinService.verify(pin); if (!valid) { _recordFailure(); return false; } _failedAttempts = 0; _errorMessage = null; _notify(); return true; } catch (_) { _errorMessage = 'Could not verify the PIN.'; _notify(); return false; } } void _recordFailure() { _failedAttempts++; if (_failedAttempts >= 5) { _failedAttempts = 0; _cooldownUntil = DateTime.now().add(const Duration(seconds: 30)); _cooldownTimer?.cancel(); _cooldownTimer = Timer(const Duration(seconds: 30), () { _errorMessage = null; _notify(); }); _errorMessage = 'Too many attempts. Try again in 30s.'; } else { _errorMessage = 'Incorrect PIN. Try again.'; } _notify(); } void clearError() { if (_errorMessage == null) return; _errorMessage = null; _notify(); } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _cooldownTimer?.cancel(); super.dispose(); } }