From 160b4e4cdac5c387102349829e5d0bdb3942642d Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sun, 12 Jul 2026 12:03:26 +0200 Subject: [PATCH] feat: add pin lock / fix performance on history view --- lib/app/app.dart | 20 +- lib/app/router.dart | 89 ++++--- lib/main.dart | 22 +- lib/services/pin_lock_service.dart | 56 ++++ lib/viewmodels/pin_lock_view_model.dart | 134 ++++++++++ lib/views/bar_screen_view.dart | 9 + lib/views/history_screen_view.dart | 5 +- lib/views/pin_lock_view.dart | 331 ++++++++++++++++++++++++ pubspec.lock | 66 ++++- pubspec.yaml | 2 + 10 files changed, 682 insertions(+), 52 deletions(-) create mode 100644 lib/services/pin_lock_service.dart create mode 100644 lib/viewmodels/pin_lock_view_model.dart create mode 100644 lib/views/pin_lock_view.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 75c2752..29c1f66 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,23 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:kooltab2/theme.dart'; -import 'router.dart'; - class KoolTabApp extends StatelessWidget { - const KoolTabApp({super.key}); + const KoolTabApp({ + super.key, + required this.router, + }); + + final GoRouter router; @override Widget build(BuildContext context) { return MaterialApp.router( title: 'KoolTab', debugShowCheckedModeBanner: false, - routerConfig: appRouter, - // theme: ThemeData( - // useMaterial3: false, - // colorSchemeSeed: Colors.blueAccent, - // brightness: Brightness.light, - // ), - theme: darkTheme + routerConfig: router, + // theme: lightTheme, + theme: darkTheme, ); } } \ No newline at end of file diff --git a/lib/app/router.dart b/lib/app/router.dart index e3d199a..cb3ee29 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,45 +1,72 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../viewmodels/pin_lock_view_model.dart'; import '../views/bar_screen_view.dart'; import '../views/history_screen_view.dart'; +import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; import '../views/product_list_view.dart'; final RouteObserver routeObserver = RouteObserver(); -final appRouter = GoRouter( - initialLocation: '/bar', - observers: [ - routeObserver, - ], - routes: [ - GoRoute( - path: '/bar', - builder: (context, state) => const BarScreenView(), - ), +GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { + return GoRouter( + initialLocation: '/bar', + observers: [routeObserver], + refreshListenable: pinLockViewModel, + redirect: (context, state) { + if (!pinLockViewModel.hasLoaded) return null; - GoRoute( - path: '/products', - builder: (context, state) => const ProductListView(), - routes: [ - GoRoute( - path: 'new', - builder: (context, state) => const ProductFormView(), - ), - GoRoute( - path: ':id/edit', - builder: (context, state) { - final productId = state.pathParameters['id']!; + final needsSetup = !pinLockViewModel.isPinSet; + final isLocked = + pinLockViewModel.isPinSet && !pinLockViewModel.isUnlocked; + final mustBeOnLock = needsSetup || isLocked; - return ProductFormView(productId: productId); + final goingToLock = state.matchedLocation == '/lock'; + + if (mustBeOnLock && !goingToLock) return '/lock'; + if (!mustBeOnLock && goingToLock) return '/bar'; + + return null; + }, + routes: [ + GoRoute( + path: '/lock', + builder: (context, state) => PinEntryView( + mode: pinLockViewModel.isPinSet + ? PinEntryMode.unlock + : PinEntryMode.create, + onSuccess: () { + context.go("/bar"); }, ), - ], - ), - GoRoute( - path: '/history', - builder: (context, state) => const HistoryScreenView(), - ), - ], -); \ No newline at end of file + ), + + GoRoute(path: '/bar', builder: (context, state) => const BarScreenView()), + + GoRoute( + path: '/products', + builder: (context, state) => const ProductListView(), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) => const ProductFormView(), + ), + GoRoute( + path: ':id/edit', + builder: (context, state) { + final productId = state.pathParameters['id']!; + + return ProductFormView(productId: productId); + }, + ), + ], + ), + GoRoute( + path: '/history', + builder: (context, state) => const HistoryScreenView(), + ), + ], + ); +} diff --git a/lib/main.dart b/lib/main.dart index e4b8458..8709f17 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; +import 'package:kooltab2/services/pin_lock_service.dart'; import 'package:kooltab2/viewmodels/history_view_model.dart'; +import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; import 'package:provider/provider.dart'; import 'app/app.dart'; import 'app/app_bootstrap.dart'; +import 'app/router.dart'; import 'database/app_database.dart'; import 'services/bar_tab_service.dart'; import 'services/product_service.dart'; @@ -16,16 +19,19 @@ import 'viewmodels/product_list_view_model.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await initializeDateFormatting('nl_BE', null); Intl.defaultLocale = 'nl_BE'; - await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); + final pinLockViewModel = PinLockViewModel(pinLockService: PinLockService()); + await pinLockViewModel.ensureLoaded(); + + final appRouter = createAppRouter(pinLockViewModel); + runApp( MultiProvider( providers: [ @@ -54,11 +60,15 @@ Future main() async { ), ), ChangeNotifierProvider( - create: (context) => HistoryViewModel(barTabService: context.read()), - ) + create: (context) => + HistoryViewModel(barTabService: context.read()), + ), + ChangeNotifierProvider.value( + value: pinLockViewModel, + ), ], - child: const AppBootstrap( - child: KoolTabApp(), + child: AppBootstrap( + child: KoolTabApp(router: appRouter), ), ), ); diff --git a/lib/services/pin_lock_service.dart b/lib/services/pin_lock_service.dart new file mode 100644 index 0000000..fa7aa32 --- /dev/null +++ b/lib/services/pin_lock_service.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Stores and verifies a PIN. The raw PIN is never persisted — only a +/// salted SHA-256 hash of it, kept in secure storage (Keychain on iOS, +/// EncryptedSharedPreferences/Keystore on Android). +class PinLockService { + PinLockService({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _saltKey = 'pin_salt'; + static const _hashKey = 'pin_hash'; + + final FlutterSecureStorage _storage; + + Future hasPin() async { + final hash = await _storage.read(key: _hashKey); + return hash != null && hash.isNotEmpty; + } + + Future setPin(String pin) async { + final salt = _generateSalt(); + final hash = _hash(pin, salt); + + await _storage.write(key: _saltKey, value: salt); + await _storage.write(key: _hashKey, value: hash); + } + + Future verifyPin(String pin) async { + final salt = await _storage.read(key: _saltKey); + final storedHash = await _storage.read(key: _hashKey); + + if (salt == null || storedHash == null) return false; + + return _hash(pin, salt) == storedHash; + } + + Future clearPin() async { + await _storage.delete(key: _saltKey); + await _storage.delete(key: _hashKey); + } + + String _generateSalt([int length = 16]) { + final random = Random.secure(); + final bytes = List.generate(length, (_) => random.nextInt(256)); + return base64UrlEncode(bytes); + } + + String _hash(String pin, String salt) { + final bytes = utf8.encode('$salt:$pin'); + return sha256.convert(bytes).toString(); + } +} \ No newline at end of file diff --git a/lib/viewmodels/pin_lock_view_model.dart b/lib/viewmodels/pin_lock_view_model.dart new file mode 100644 index 0000000..67aa0ef --- /dev/null +++ b/lib/viewmodels/pin_lock_view_model.dart @@ -0,0 +1,134 @@ +import 'package:flutter/foundation.dart'; + +import '../services/pin_lock_service.dart'; + +class PinLockViewModel extends ChangeNotifier { + final PinLockService pinLockService; + + PinLockViewModel({required this.pinLockService}); + + bool _isPinSet = false; + bool _isUnlocked = false; + bool _isLoading = false; + bool _hasLoaded = false; + String? _errorMessage; + + bool get isPinSet => _isPinSet; + + bool get isUnlocked => _isUnlocked; + + bool get isLoading => _isLoading; + + bool get hasLoaded => _hasLoaded; + + String? get errorMessage => _errorMessage; + + Future ensureLoaded() async { + if (_hasLoaded || _isLoading) return; + + await load(); + } + + /// Checks whether a PIN has already been configured on this device. + Future load() async { + if (_isLoading) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _isPinSet = await pinLockService.hasPin(); + } catch (_) { + _errorMessage = 'Could not check PIN status.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } + } + + /// Creates a new PIN (first-time setup, or after disabling an old one). + /// Unlocks the app immediately on success, since the person just proved + /// they know it by typing it. + Future setPin(String pin) async { + _errorMessage = null; + + try { + await pinLockService.setPin(pin); + _isPinSet = true; + _isUnlocked = true; + notifyListeners(); + return true; + } catch (_) { + _errorMessage = 'Could not save PIN.'; + notifyListeners(); + return false; + } + } + + /// Checks an entered PIN against the stored one, unlocking on match. + Future verify(String pin) async { + _errorMessage = null; + + try { + final matches = await pinLockService.verifyPin(pin); + + if (matches) { + _isUnlocked = true; + notifyListeners(); + return true; + } + + _errorMessage = 'Incorrect PIN.'; + notifyListeners(); + return false; + } catch (_) { + _errorMessage = 'Could not verify PIN.'; + notifyListeners(); + return false; + } + } + + /// Replaces the current PIN. Requires the current PIN to match first. + Future changePin({ + required String currentPin, + required String newPin, + }) async { + final currentMatches = await pinLockService.verifyPin(currentPin); + + if (!currentMatches) { + _errorMessage = 'Current PIN is incorrect.'; + notifyListeners(); + return false; + } + + return setPin(newPin); + } + + /// Removes the PIN entirely. Requires the current PIN to confirm. + Future disablePin(String currentPin) async { + final matches = await pinLockService.verifyPin(currentPin); + + if (!matches) { + _errorMessage = 'Current PIN is incorrect.'; + notifyListeners(); + return false; + } + + await pinLockService.clearPin(); + _isPinSet = false; + notifyListeners(); + return true; + } + + /// Re-locks the app. Call this on app backgrounding (e.g. from a + /// WidgetsBindingObserver on AppLifecycleState.paused) if you want the + /// PIN required again after the app is put away, not just on cold start. + void lock() { + if (!_isUnlocked) return; + + _isUnlocked = false; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index a4484f1..056168d 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widget_previews.dart'; import 'package:go_router/go_router.dart'; +import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; import 'package:kooltab2/viewmodels/product_list_view_model.dart'; import 'package:provider/provider.dart'; import 'dart:io'; @@ -41,6 +42,14 @@ class BarScreenView extends StatelessWidget { onPressed: viewModel.load, icon: const Icon(Icons.refresh_rounded), ), + const SizedBox(width: 6), + IconButton( + tooltip: 'logout', + onPressed: (){ + Provider.of(context, listen: false).lock(); + }, + icon: const Icon(Icons.logout) + ), const SizedBox(width: 8), ], ), diff --git a/lib/views/history_screen_view.dart b/lib/views/history_screen_view.dart index 5b3e09b..00fc8ff 100644 --- a/lib/views/history_screen_view.dart +++ b/lib/views/history_screen_view.dart @@ -28,10 +28,7 @@ class _HistoryScreenViewState extends State with RouteAware { @override void didChangeDependencies() { super.didChangeDependencies(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().load(); - }); + routeObserver.subscribe(this, ModalRoute.of(context)! as PageRoute); } @override diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart new file mode 100644 index 0000000..61047c5 --- /dev/null +++ b/lib/views/pin_lock_view.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../viewmodels/pin_lock_view_model.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; + + const PinEntryView({ + super.key, + required this.mode, + this.onSuccess, + }); + + @override + State createState() => _PinEntryViewState(); +} + +class _PinEntryViewState extends State { + String _digits = ''; + String? _firstEntry; + bool _isConfirmStep = false; + bool _isSubmitting = false; + String? _localError; + bool _shake = false; + + bool get _isCreateFlow => widget.mode == PinEntryMode.create; + + String get _title { + if (!_isCreateFlow) return 'Enter PIN'; + return _isConfirmStep ? 'Confirm PIN' : 'Create a PIN'; + } + + String? get _subtitle { + if (!_isCreateFlow) return null; + return _isConfirmStep + ? 'Enter the same PIN again' + : 'Youʼll use this to unlock the app'; + } + + 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 _handleComplete() async { + final viewModel = context.read(); + + if (_isCreateFlow && !_isConfirmStep) { + // First entry of a new PIN — stash it, then ask for confirmation. + final entered = _digits; + + setState(() { + _firstEntry = entered; + _isConfirmStep = true; + _digits = ''; + }); + return; + } + + if (_isCreateFlow && _isConfirmStep) { + if (_digits != _firstEntry) { + setState(() { + _firstEntry = null; + _isConfirmStep = false; + }); + _fail('PINs didnʼt match. Try again.'); + return; + } + + setState(() => _isSubmitting = true); + final ok = await viewModel.setPin(_digits); + if (!mounted) return; + setState(() => _isSubmitting = false); + + if (ok) { + widget.onSuccess?.call(); + } else { + setState(() { + _firstEntry = null; + _isConfirmStep = false; + }); + _fail(viewModel.errorMessage ?? 'Something went wrong.'); + } + return; + } + + // Unlock flow. + setState(() => _isSubmitting = true); + final ok = await viewModel.verify(_digits); + if (!mounted) return; + setState(() => _isSubmitting = false); + + if (ok) { + widget.onSuccess?.call(); + } else { + _fail(viewModel.errorMessage ?? 'Incorrect PIN.'); + } + } + + 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; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Spacer(flex: 2), + Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary), + const SizedBox(height: 16), + Text(_title, style: Theme.of(context).textTheme.headlineMedium), + if (_subtitle != null) ...[ + const SizedBox(height: 6), + Text( + _subtitle!, + 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.withOpacity(0.3), + width: 1.4, + ), + ), + ); + }), + ); + } +} + +class _PinKeypad extends StatelessWidget { + final bool enabled; + final ValueChanged 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: 6), + child: Row( + spacing: 16, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final digit in row) + _KeypadButton( + label: digit, + onTap: enabled ? () => onDigit(digit) : null, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + spacing: 16, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const _KeypadButton(isSpacer: true, onTap: null), + _KeypadButton( + label: '0', + onTap: enabled ? () => onDigit('0') : null, + ), + _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: 72, height: 72); + } + + final scheme = Theme.of(context).colorScheme; + + return SizedBox( + width: 72, + height: 72, + child: Material( + color: scheme.onSurface.withOpacity(0.04), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Center( + child: icon != null + ? Icon(icon, color: scheme.onSurface.withOpacity(0.8)) + : Text( + label!, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: onTap == null + ? scheme.onSurface.withOpacity(0.3) + : scheme.onSurface, + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 4988a92..996c52d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -294,6 +294,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_slidable: dependency: "direct main" description: @@ -464,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -877,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index db0dbdd..7f38a8c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: path: ^1.9.1 flutter_slidable: ^4.0.3 intl: ^0.20.3 + flutter_secure_storage: ^9.0.0 + crypto: ^3.0.0 dev_dependencies: