feat: add pin lock / fix performance on history view
This commit is contained in:
+10
-10
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-6
@@ -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',
|
||||
@@ -43,3 +69,4 @@ final appRouter = GoRouter(
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+16
-6
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user