feat: add pin lock / fix performance on history view

This commit is contained in:
2026-07-12 12:03:26 +02:00
parent ae3d2dd302
commit 160b4e4cda
10 changed files with 682 additions and 52 deletions
+10 -10
View File
@@ -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,
);
}
}
+34 -7
View File
@@ -1,23 +1,49 @@
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<PageRoute> routeObserver = RouteObserver<PageRoute>();
final appRouter = GoRouter(
GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
return GoRouter(
initialLocation: '/bar',
observers: [
routeObserver,
],
observers: [routeObserver],
refreshListenable: pinLockViewModel,
redirect: (context, state) {
if (!pinLockViewModel.hasLoaded) return null;
final needsSetup = !pinLockViewModel.isPinSet;
final isLocked =
pinLockViewModel.isPinSet && !pinLockViewModel.isUnlocked;
final mustBeOnLock = needsSetup || isLocked;
final goingToLock = state.matchedLocation == '/lock';
if (mustBeOnLock && !goingToLock) return '/lock';
if (!mustBeOnLock && goingToLock) return '/bar';
return null;
},
routes: [
GoRoute(
path: '/bar',
builder: (context, state) => const BarScreenView(),
path: '/lock',
builder: (context, state) => PinEntryView(
mode: pinLockViewModel.isPinSet
? PinEntryMode.unlock
: PinEntryMode.create,
onSuccess: () {
context.go("/bar");
},
),
),
GoRoute(path: '/bar', builder: (context, state) => const BarScreenView()),
GoRoute(
path: '/products',
@@ -42,4 +68,5 @@ final appRouter = GoRouter(
builder: (context, state) => const HistoryScreenView(),
),
],
);
);
}
+16 -6
View File
@@ -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<void> 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<void> main() async {
),
),
ChangeNotifierProvider<HistoryViewModel>(
create: (context) => HistoryViewModel(barTabService: context.read<BarTabService>()),
)
create: (context) =>
HistoryViewModel(barTabService: context.read<BarTabService>()),
),
ChangeNotifierProvider<PinLockViewModel>.value(
value: pinLockViewModel,
),
],
child: const AppBootstrap(
child: KoolTabApp(),
child: AppBootstrap(
child: KoolTabApp(router: appRouter),
),
),
);
+56
View File
@@ -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<bool> hasPin() async {
final hash = await _storage.read(key: _hashKey);
return hash != null && hash.isNotEmpty;
}
Future<void> 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<bool> 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<void> clearPin() async {
await _storage.delete(key: _saltKey);
await _storage.delete(key: _hashKey);
}
String _generateSalt([int length = 16]) {
final random = Random.secure();
final bytes = List<int>.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();
}
}
+134
View File
@@ -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<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
await load();
}
/// Checks whether a PIN has already been configured on this device.
Future<void> 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<bool> 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<bool> 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<bool> 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<bool> 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();
}
}
+9
View File
@@ -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<PinLockViewModel>(context, listen: false).lock();
},
icon: const Icon(Icons.logout)
),
const SizedBox(width: 8),
],
),
+1 -4
View File
@@ -28,10 +28,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HistoryViewModel>().load();
});
routeObserver.subscribe(this, ModalRoute.of(context)! as PageRoute);
}
@override
+331
View File
@@ -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<PinEntryView> createState() => _PinEntryViewState();
}
class _PinEntryViewState extends State<PinEntryView> {
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<void> _handleComplete() async {
final viewModel = context.read<PinLockViewModel>();
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<String> 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,
),
),
),
),
),
);
}
}
+65 -1
View File
@@ -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:
+2
View File
@@ -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: