From 812fcc892be353a7b1663e5fa6a6d1517c2096f8 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Tue, 4 Aug 2026 01:09:01 +0200 Subject: [PATCH] feat: add setting for auto-lock fix: fix sideswipe --- lib/app/app.dart | 2 + lib/app/app_lifecycle_lock.dart | 5 +- lib/database/app_database.dart | 12 ++- lib/database/app_database.g.dart | 84 +++++++++++++++- lib/l10n/app_en.arb | 6 ++ lib/l10n/app_localizations.dart | 36 +++++++ lib/l10n/app_localizations_en.dart | 19 ++++ lib/l10n/app_localizations_helpers.dart | 2 + lib/l10n/app_localizations_nl.dart | 20 ++++ lib/l10n/app_nl.arb | 6 ++ lib/models/settings.dart | 15 ++- lib/services/bar_tab_service.dart | 60 +++++++++++ lib/services/settings_service.dart | 2 + lib/viewmodels/bar_screen_view_model.dart | 31 ++++++ lib/viewmodels/settings_view_model.dart | 3 + lib/views/bar_screen_view.dart | 38 ++++--- lib/views/dialogs/edit_tab_dialog.dart | 117 ++++++++++++++++++++++ lib/views/settings_view.dart | 8 ++ test/app_lifecycle_lock_test.dart | 19 ++++ test/bar_screen_view_model_test.dart | 33 ++++++ test/bar_tab_service_test.dart | 34 +++++++ test/edit_tab_dialog_test.dart | 69 +++++++++++++ test/settings_service_test.dart | 36 +++++++ 23 files changed, 629 insertions(+), 28 deletions(-) create mode 100644 lib/views/dialogs/edit_tab_dialog.dart create mode 100644 test/edit_tab_dialog_test.dart create mode 100644 test/settings_service_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 0caecbb..d88cd7c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -27,6 +27,8 @@ class _KoolTabAppState extends State { _lifecycleLockObserver = AppLifecycleLockObserver( onLock: () => context.read().lock(), + shouldLock: () => + context.read().settings.autoLockEnabled, ); WidgetsBinding.instance.addObserver(_lifecycleLockObserver); diff --git a/lib/app/app_lifecycle_lock.dart b/lib/app/app_lifecycle_lock.dart index b86ca06..0735678 100644 --- a/lib/app/app_lifecycle_lock.dart +++ b/lib/app/app_lifecycle_lock.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; class AppLifecycleLockObserver extends WidgetsBindingObserver { final VoidCallback onLock; + final bool Function()? shouldLock; - AppLifecycleLockObserver({required this.onLock}); + AppLifecycleLockObserver({required this.onLock, this.shouldLock}); @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -12,7 +13,7 @@ class AppLifecycleLockObserver extends WidgetsBindingObserver { case AppLifecycleState.hidden: case AppLifecycleState.paused: case AppLifecycleState.detached: - onLock(); + if (shouldLock?.call() ?? true) onLock(); case AppLifecycleState.resumed: break; } diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8e37ea0..e0b8925 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -106,6 +106,9 @@ class AppSettingsTable extends Table { BoolColumn get pinRequired => boolean().withDefault(const Constant(false))(); + BoolColumn get autoLockEnabled => + boolean().withDefault(const Constant(true))(); + TextColumn get themeMode => text().withDefault(const Constant('system'))(); TextColumn get language => text().withDefault(const Constant('system'))(); @@ -132,7 +135,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration { @@ -181,6 +184,13 @@ class AppDatabase extends _$AppDatabase { appSettingsTable.barGridRows, ); } + + if (from < 9 && !await hasSettingsColumn('auto_lock_enabled')) { + await migrator.addColumn( + appSettingsTable, + appSettingsTable.autoLockEnabled, + ); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 8438ad9..6d8c248 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2207,6 +2207,21 @@ class $AppSettingsTableTable extends AppSettingsTable ), defaultValue: const Constant(false), ); + static const VerificationMeta _autoLockEnabledMeta = const VerificationMeta( + 'autoLockEnabled', + ); + @override + late final GeneratedColumn autoLockEnabled = GeneratedColumn( + 'auto_lock_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("auto_lock_enabled" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _themeModeMeta = const VerificationMeta( 'themeMode', ); @@ -2247,6 +2262,7 @@ class $AppSettingsTableTable extends AppSettingsTable List get $columns => [ id, pinRequired, + autoLockEnabled, themeMode, language, barGridRows, @@ -2277,6 +2293,15 @@ class $AppSettingsTableTable extends AppSettingsTable ), ); } + if (data.containsKey('auto_lock_enabled')) { + context.handle( + _autoLockEnabledMeta, + autoLockEnabled.isAcceptableOrUnknown( + data['auto_lock_enabled']!, + _autoLockEnabledMeta, + ), + ); + } if (data.containsKey('theme_mode')) { context.handle( _themeModeMeta, @@ -2315,6 +2340,10 @@ class $AppSettingsTableTable extends AppSettingsTable DriftSqlType.bool, data['${effectivePrefix}pin_required'], )!, + autoLockEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}auto_lock_enabled'], + )!, themeMode: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}theme_mode'], @@ -2339,12 +2368,14 @@ class $AppSettingsTableTable extends AppSettingsTable class AppSettingsRow extends DataClass implements Insertable { final String id; final bool pinRequired; + final bool autoLockEnabled; final String themeMode; final String language; final int barGridRows; const AppSettingsRow({ required this.id, required this.pinRequired, + required this.autoLockEnabled, required this.themeMode, required this.language, required this.barGridRows, @@ -2354,6 +2385,7 @@ class AppSettingsRow extends DataClass implements Insertable { final map = {}; map['id'] = Variable(id); map['pin_required'] = Variable(pinRequired); + map['auto_lock_enabled'] = Variable(autoLockEnabled); map['theme_mode'] = Variable(themeMode); map['language'] = Variable(language); map['bar_grid_rows'] = Variable(barGridRows); @@ -2364,6 +2396,7 @@ class AppSettingsRow extends DataClass implements Insertable { return AppSettingsTableCompanion( id: Value(id), pinRequired: Value(pinRequired), + autoLockEnabled: Value(autoLockEnabled), themeMode: Value(themeMode), language: Value(language), barGridRows: Value(barGridRows), @@ -2378,6 +2411,7 @@ class AppSettingsRow extends DataClass implements Insertable { return AppSettingsRow( id: serializer.fromJson(json['id']), pinRequired: serializer.fromJson(json['pinRequired']), + autoLockEnabled: serializer.fromJson(json['autoLockEnabled']), themeMode: serializer.fromJson(json['themeMode']), language: serializer.fromJson(json['language']), barGridRows: serializer.fromJson(json['barGridRows']), @@ -2389,6 +2423,7 @@ class AppSettingsRow extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'pinRequired': serializer.toJson(pinRequired), + 'autoLockEnabled': serializer.toJson(autoLockEnabled), 'themeMode': serializer.toJson(themeMode), 'language': serializer.toJson(language), 'barGridRows': serializer.toJson(barGridRows), @@ -2398,12 +2433,14 @@ class AppSettingsRow extends DataClass implements Insertable { AppSettingsRow copyWith({ String? id, bool? pinRequired, + bool? autoLockEnabled, String? themeMode, String? language, int? barGridRows, }) => AppSettingsRow( id: id ?? this.id, pinRequired: pinRequired ?? this.pinRequired, + autoLockEnabled: autoLockEnabled ?? this.autoLockEnabled, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, barGridRows: barGridRows ?? this.barGridRows, @@ -2414,6 +2451,9 @@ class AppSettingsRow extends DataClass implements Insertable { pinRequired: data.pinRequired.present ? data.pinRequired.value : this.pinRequired, + autoLockEnabled: data.autoLockEnabled.present + ? data.autoLockEnabled.value + : this.autoLockEnabled, themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, language: data.language.present ? data.language.value : this.language, barGridRows: data.barGridRows.present @@ -2427,6 +2467,7 @@ class AppSettingsRow extends DataClass implements Insertable { return (StringBuffer('AppSettingsRow(') ..write('id: $id, ') ..write('pinRequired: $pinRequired, ') + ..write('autoLockEnabled: $autoLockEnabled, ') ..write('themeMode: $themeMode, ') ..write('language: $language, ') ..write('barGridRows: $barGridRows') @@ -2435,14 +2476,21 @@ class AppSettingsRow extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(id, pinRequired, themeMode, language, barGridRows); + int get hashCode => Object.hash( + id, + pinRequired, + autoLockEnabled, + themeMode, + language, + barGridRows, + ); @override bool operator ==(Object other) => identical(this, other) || (other is AppSettingsRow && other.id == this.id && other.pinRequired == this.pinRequired && + other.autoLockEnabled == this.autoLockEnabled && other.themeMode == this.themeMode && other.language == this.language && other.barGridRows == this.barGridRows); @@ -2451,6 +2499,7 @@ class AppSettingsRow extends DataClass implements Insertable { class AppSettingsTableCompanion extends UpdateCompanion { final Value id; final Value pinRequired; + final Value autoLockEnabled; final Value themeMode; final Value language; final Value barGridRows; @@ -2458,6 +2507,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { const AppSettingsTableCompanion({ this.id = const Value.absent(), this.pinRequired = const Value.absent(), + this.autoLockEnabled = const Value.absent(), this.themeMode = const Value.absent(), this.language = const Value.absent(), this.barGridRows = const Value.absent(), @@ -2466,6 +2516,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { AppSettingsTableCompanion.insert({ required String id, this.pinRequired = const Value.absent(), + this.autoLockEnabled = const Value.absent(), this.themeMode = const Value.absent(), this.language = const Value.absent(), this.barGridRows = const Value.absent(), @@ -2474,6 +2525,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? pinRequired, + Expression? autoLockEnabled, Expression? themeMode, Expression? language, Expression? barGridRows, @@ -2482,6 +2534,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (pinRequired != null) 'pin_required': pinRequired, + if (autoLockEnabled != null) 'auto_lock_enabled': autoLockEnabled, if (themeMode != null) 'theme_mode': themeMode, if (language != null) 'language': language, if (barGridRows != null) 'bar_grid_rows': barGridRows, @@ -2492,6 +2545,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { AppSettingsTableCompanion copyWith({ Value? id, Value? pinRequired, + Value? autoLockEnabled, Value? themeMode, Value? language, Value? barGridRows, @@ -2500,6 +2554,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { return AppSettingsTableCompanion( id: id ?? this.id, pinRequired: pinRequired ?? this.pinRequired, + autoLockEnabled: autoLockEnabled ?? this.autoLockEnabled, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, barGridRows: barGridRows ?? this.barGridRows, @@ -2516,6 +2571,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { if (pinRequired.present) { map['pin_required'] = Variable(pinRequired.value); } + if (autoLockEnabled.present) { + map['auto_lock_enabled'] = Variable(autoLockEnabled.value); + } if (themeMode.present) { map['theme_mode'] = Variable(themeMode.value); } @@ -2536,6 +2594,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { return (StringBuffer('AppSettingsTableCompanion(') ..write('id: $id, ') ..write('pinRequired: $pinRequired, ') + ..write('autoLockEnabled: $autoLockEnabled, ') ..write('themeMode: $themeMode, ') ..write('language: $language, ') ..write('barGridRows: $barGridRows, ') @@ -4125,6 +4184,7 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder = AppSettingsTableCompanion Function({ required String id, Value pinRequired, + Value autoLockEnabled, Value themeMode, Value language, Value barGridRows, @@ -4134,6 +4194,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder = AppSettingsTableCompanion Function({ Value id, Value pinRequired, + Value autoLockEnabled, Value themeMode, Value language, Value barGridRows, @@ -4159,6 +4220,11 @@ class $$AppSettingsTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get autoLockEnabled => $composableBuilder( + column: $table.autoLockEnabled, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get themeMode => $composableBuilder( column: $table.themeMode, builder: (column) => ColumnFilters(column), @@ -4194,6 +4260,11 @@ class $$AppSettingsTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get autoLockEnabled => $composableBuilder( + column: $table.autoLockEnabled, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get themeMode => $composableBuilder( column: $table.themeMode, builder: (column) => ColumnOrderings(column), @@ -4227,6 +4298,11 @@ class $$AppSettingsTableTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get autoLockEnabled => $composableBuilder( + column: $table.autoLockEnabled, + builder: (column) => column, + ); + GeneratedColumn get themeMode => $composableBuilder(column: $table.themeMode, builder: (column) => column); @@ -4278,6 +4354,7 @@ class $$AppSettingsTableTableTableManager ({ Value id = const Value.absent(), Value pinRequired = const Value.absent(), + Value autoLockEnabled = const Value.absent(), Value themeMode = const Value.absent(), Value language = const Value.absent(), Value barGridRows = const Value.absent(), @@ -4285,6 +4362,7 @@ class $$AppSettingsTableTableTableManager }) => AppSettingsTableCompanion( id: id, pinRequired: pinRequired, + autoLockEnabled: autoLockEnabled, themeMode: themeMode, language: language, barGridRows: barGridRows, @@ -4294,6 +4372,7 @@ class $$AppSettingsTableTableTableManager ({ required String id, Value pinRequired = const Value.absent(), + Value autoLockEnabled = const Value.absent(), Value themeMode = const Value.absent(), Value language = const Value.absent(), Value barGridRows = const Value.absent(), @@ -4301,6 +4380,7 @@ class $$AppSettingsTableTableTableManager }) => AppSettingsTableCompanion.insert( id: id, pinRequired: pinRequired, + autoLockEnabled: autoLockEnabled, themeMode: themeMode, language: language, barGridRows: barGridRows, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 239ea31..c8180b5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -25,6 +25,7 @@ "settingsTitle": "Settings", "security": "Security", "pinRequired": "PIN Required", + "autoLockOnExit": "Auto-lock on phone lock or app exit", "changePin": "Change PIN", "appearance": "Appearance", "language": "Language", @@ -71,7 +72,12 @@ "selectOrOpenTab": "Select or open a tab", "noOpenTabs": "No open tabs", "edit": "Edit", + "editTab": "Edit tab", "close": "Close", + "deleteTab": "Delete tab?", + "deleteTabDescription": "This will permanently delete the tab and return all its items to stock.", + "couldNotRenameTab": "Could not rename tab.", + "couldNotDeleteTab": "Could not delete tab.", "tabItemSummary": "{count, plural, =1 {1 item - {total}} other {{count} items - {total}}}", "@tabItemSummary": {"placeholders": {"count": {"type": "int"}, "total": {"type": "String"}}}, "tabItemCount": "{count, plural, =1 {1 item} other {{count} items}}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3e82184..f53bf6d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -242,6 +242,12 @@ abstract class AppLocalizations { /// **'PIN Required'** String get pinRequired; + /// No description provided for @autoLockOnExit. + /// + /// In en, this message translates to: + /// **'Auto-lock on phone lock or app exit'** + String get autoLockOnExit; + /// No description provided for @changePin. /// /// In en, this message translates to: @@ -506,12 +512,42 @@ abstract class AppLocalizations { /// **'Edit'** String get edit; + /// No description provided for @editTab. + /// + /// In en, this message translates to: + /// **'Edit tab'** + String get editTab; + /// No description provided for @close. /// /// In en, this message translates to: /// **'Close'** String get close; + /// No description provided for @deleteTab. + /// + /// In en, this message translates to: + /// **'Delete tab?'** + String get deleteTab; + + /// No description provided for @deleteTabDescription. + /// + /// In en, this message translates to: + /// **'This will permanently delete the tab and return all its items to stock.'** + String get deleteTabDescription; + + /// No description provided for @couldNotRenameTab. + /// + /// In en, this message translates to: + /// **'Could not rename tab.'** + String get couldNotRenameTab; + + /// No description provided for @couldNotDeleteTab. + /// + /// In en, this message translates to: + /// **'Could not delete tab.'** + String get couldNotDeleteTab; + /// No description provided for @tabItemSummary. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 801db9f..745e21a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -82,6 +82,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pinRequired => 'PIN Required'; + @override + String get autoLockOnExit => 'Auto-lock on phone lock or app exit'; + @override String get changePin => 'Change PIN'; @@ -219,9 +222,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get edit => 'Edit'; + @override + String get editTab => 'Edit tab'; + @override String get close => 'Close'; + @override + String get deleteTab => 'Delete tab?'; + + @override + String get deleteTabDescription => + 'This will permanently delete the tab and return all its items to stock.'; + + @override + String get couldNotRenameTab => 'Could not rename tab.'; + + @override + String get couldNotDeleteTab => 'Could not delete tab.'; + @override String tabItemSummary(int count, String total) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_helpers.dart b/lib/l10n/app_localizations_helpers.dart index 96cf5d3..64505aa 100644 --- a/lib/l10n/app_localizations_helpers.dart +++ b/lib/l10n/app_localizations_helpers.dart @@ -23,6 +23,8 @@ extension AppLocalizationsHelpers on AppLocalizations { 'Could not add product to tab.' => couldNotAddProductToTab, 'Could not update item quantity.' => couldNotUpdateQuantity, 'Could not close tab.' => couldNotCloseTab, + 'Could not rename tab.' => couldNotRenameTab, + 'Could not delete tab.' => couldNotDeleteTab, 'Could not reload tabs.' => couldNotReloadTabs, 'Could not load tab history.' => couldNotLoadHistory, 'Could not load more tabs.' => couldNotLoadMoreTabs, diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 70e5c0f..50f468e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -82,6 +82,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pinRequired => 'PIN vereist'; + @override + String get autoLockOnExit => + 'Automatisch vergrendelen bij telefoonslot of afsluiten'; + @override String get changePin => 'PIN wijzigen'; @@ -219,9 +223,25 @@ class AppLocalizationsNl extends AppLocalizations { @override String get edit => 'Bewerken'; + @override + String get editTab => 'Poef bewerken'; + @override String get close => 'Sluiten'; + @override + String get deleteTab => 'Poef verwijderen?'; + + @override + String get deleteTabDescription => + 'Deze poef wordt definitief verwijderd en alle items worden terug aan de voorraad toegevoegd.'; + + @override + String get couldNotRenameTab => 'Poef kon niet worden hernoemd.'; + + @override + String get couldNotDeleteTab => 'Poef kon niet worden verwijderd.'; + @override String tabItemSummary(int count, String total) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index e306d02..7a1de97 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -25,6 +25,7 @@ "settingsTitle": "Instellingen", "security": "Beveiliging", "pinRequired": "PIN vereist", + "autoLockOnExit": "Automatisch vergrendelen bij telefoonslot of afsluiten", "changePin": "PIN wijzigen", "appearance": "Uiterlijk", "language": "Taal", @@ -71,7 +72,12 @@ "selectOrOpenTab": "Selecteer of open een poef", "noOpenTabs": "Geen open poefs", "edit": "Bewerken", + "editTab": "Poef bewerken", "close": "Sluiten", + "deleteTab": "Poef verwijderen?", + "deleteTabDescription": "Deze poef wordt definitief verwijderd en alle items worden terug aan de voorraad toegevoegd.", + "couldNotRenameTab": "Poef kon niet worden hernoemd.", + "couldNotDeleteTab": "Poef kon niet worden verwijderd.", "tabItemSummary": "{count, plural, =1 {1 item - {total}} other {{count} items - {total}}}", "@tabItemSummary": {"placeholders": {"count": {"type": "int"}, "total": {"type": "String"}}}, "tabItemCount": "{count, plural, =1 {1 item} other {{count} items}}", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6d9a23d..f29921f 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -4,12 +4,14 @@ enum AppLanguage { system, english, dutch } class AppSettings { final bool pinRequired; + final bool autoLockEnabled; final AppThemeMode themeMode; final AppLanguage language; final int barGridRows; const AppSettings({ required this.pinRequired, + this.autoLockEnabled = true, required this.themeMode, this.language = AppLanguage.system, this.barGridRows = 3, @@ -17,12 +19,14 @@ class AppSettings { AppSettings copyWith({ bool? pinRequired, + bool? autoLockEnabled, AppThemeMode? themeMode, AppLanguage? language, int? barGridRows, }) { return AppSettings( pinRequired: pinRequired ?? this.pinRequired, + autoLockEnabled: autoLockEnabled ?? this.autoLockEnabled, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, barGridRows: barGridRows ?? this.barGridRows, @@ -31,6 +35,7 @@ class AppSettings { static const AppSettings defaults = AppSettings( pinRequired: false, + autoLockEnabled: true, themeMode: AppThemeMode.system, ); @@ -39,11 +44,17 @@ class AppSettings { identical(this, other) || other is AppSettings && pinRequired == other.pinRequired && + autoLockEnabled == other.autoLockEnabled && themeMode == other.themeMode && language == other.language && barGridRows == other.barGridRows; @override - int get hashCode => - Object.hash(pinRequired, themeMode, language, barGridRows); + int get hashCode => Object.hash( + pinRequired, + autoLockEnabled, + themeMode, + language, + barGridRows, + ); } diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 82b3811..344868b 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -17,6 +17,11 @@ abstract class BarTabService { Future createTab({required String customerName}); + Future renameTab({required String tabId, required String customerName}); + + /// Deletes an open tab and returns its items to inventory. + Future> deleteTab(String tabId); + Future addProductToTab({ required String tabId, required Product product, @@ -203,6 +208,61 @@ class DriftBarTabService implements BarTabService { return tab; } + @override + Future renameTab({ + required String tabId, + required String customerName, + }) async { + final name = customerName.trim(); + if (name.isEmpty) { + throw ArgumentError.value( + customerName, + 'customerName', + 'Must not be empty', + ); + } + + final updatedRows = + await (database.update(database.barTabs) + ..where((tab) => tab.id.equals(tabId))) + .write(BarTabsCompanion(customerName: Value(name))); + + if (updatedRows != 1) { + throw StateError('Tab not found.'); + } + } + + @override + Future> deleteTab(String tabId) async { + return database.transaction(() async { + final tab = await (database.select( + database.barTabs, + )..where((row) => row.id.equals(tabId))).getSingleOrNull(); + + if (tab == null) return []; + + final items = await _getItemsForTab(tabId); + + for (final item in items) { + await _increaseProductStock(item.productId, item.quantity); + } + + await (database.delete( + database.tabItems, + )..where((item) => item.tabId.equals(tabId))).go(); + + final deletedRows = await (database.delete( + database.barTabs, + )..where((row) => row.id.equals(tabId))).go(); + + if (deletedRows != 1) { + throw StateError('Tab was changed before it could be deleted'); + } + + return items; + }); + } + @override Future addProductToTab({ required String tabId, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 067af4d..5e9c20f 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -18,6 +18,7 @@ class DriftSettingsService implements SettingsService { AppSettings _mapRowToSettings(AppSettingsRow row) { return AppSettings( pinRequired: row.pinRequired, + autoLockEnabled: row.autoLockEnabled, themeMode: AppThemeMode.values.firstWhere( (mode) => mode.name == row.themeMode, orElse: () => AppThemeMode.system, @@ -50,6 +51,7 @@ class DriftSettingsService implements SettingsService { AppSettingsTableCompanion.insert( id: _settingsId, pinRequired: Value(settings.pinRequired), + autoLockEnabled: Value(settings.autoLockEnabled), themeMode: Value(settings.themeMode.name), language: Value(settings.language.name), barGridRows: Value(settings.barGridRows), diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index e8328f2..c35416c 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -90,6 +90,37 @@ class BarScreenViewModel extends ChangeNotifier { } } + Future renameTab(String tabId, String customerName) async { + try { + await barTabService.renameTab(tabId: tabId, customerName: customerName); + await _reloadTabs(); + } catch (e, stack) { + debugPrint('BarScreenViewModel: renameTab error: $e'); + _errorMessage = 'Could not rename tab.'; + notifyListeners(); + Sentry.captureException(e, stackTrace: stack); + rethrow; + } + } + + Future deleteTab(String tabId) async { + try { + final deletedItems = await barTabService.deleteTab(tabId); + + for (final item in deletedItems) { + inventory.applyStockDelta(item.productId, item.quantity); + } + + await _reloadTabs(); + } catch (e, stack) { + debugPrint('BarScreenViewModel: deleteTab error: $e'); + _errorMessage = 'Could not delete tab.'; + notifyListeners(); + Sentry.captureException(e, stackTrace: stack); + rethrow; + } + } + Future addProductToSelectedTab(Product product) async { final tab = selectedTab; diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart index 2e62d82..d33d484 100644 --- a/lib/viewmodels/settings_view_model.dart +++ b/lib/viewmodels/settings_view_model.dart @@ -108,6 +108,9 @@ class SettingsViewModel extends ChangeNotifier { Future updatePinRequired(bool value) => _save(_settings.copyWith(pinRequired: value)); + Future updateAutoLockEnabled(bool value) => + _save(_settings.copyWith(autoLockEnabled: value)); + Future updateThemeMode(AppThemeMode mode) => _save(_settings.copyWith(themeMode: mode)); diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 90fc604..6cf8bb3 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -3,6 +3,7 @@ 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:kooltab2/views/dialogs/close_tab_dialog.dart'; +import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart'; import 'package:kooltab2/views/dialogs/new_tab_dialog.dart'; import 'package:kooltab2/views/widgets/product_tile.dart'; import 'package:provider/provider.dart'; @@ -167,7 +168,8 @@ class _BarScreenViewState extends State { onTabSelected: viewModel.selectTab, onItemQuantityChanged: viewModel.changeItemQuantity, onCloseTabPressed: () => confirmCloseTab(context), - onTabClosed: viewModel.closeTab, + onTabEdited: (tab) => showEditTabDialog(context, tab), + onTabDeleted: (tab) => confirmDeleteTab(context, tab), ), ), ], @@ -345,7 +347,8 @@ class _TabPanel extends StatefulWidget { final ValueChanged onTabSelected; final Future Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onCloseTabPressed; - final Future Function(String) onTabClosed; + final Future Function(BarTab) onTabEdited; + final Future Function(BarTab) onTabDeleted; const _TabPanel({ required this.tabs, @@ -356,7 +359,8 @@ class _TabPanel extends StatefulWidget { required this.onTabSelected, required this.onItemQuantityChanged, required this.onCloseTabPressed, - required this.onTabClosed, + required this.onTabEdited, + required this.onTabDeleted, }); @override @@ -461,7 +465,8 @@ class _TabPanelState extends State<_TabPanel> { tabs: filteredTabs, selectedTabId: widget.selectedTabId, onTabSelected: widget.onTabSelected, - onTabClosed: widget.onTabClosed, + onTabEdited: widget.onTabEdited, + onTabDeleted: widget.onTabDeleted, ), ), @@ -504,13 +509,15 @@ class _OpenTabsList extends StatelessWidget { final List tabs; final String? selectedTabId; final ValueChanged onTabSelected; - final Future Function(String) onTabClosed; + final Future Function(BarTab) onTabEdited; + final Future Function(BarTab) onTabDeleted; const _OpenTabsList({ required this.tabs, required this.selectedTabId, required this.onTabSelected, - required this.onTabClosed, + required this.onTabEdited, + required this.onTabDeleted, }); @override @@ -543,27 +550,16 @@ class _OpenTabsList extends StatelessWidget { extentRatio: 0.6, children: [ SlidableAction( - onPressed: (_) => onTabSelected(tab.id), + onPressed: (_) => onTabEdited(tab), icon: Icons.edit_outlined, label: l10n.edit, backgroundColor: Theme.of(context).colorScheme.secondary, foregroundColor: Theme.of(context).colorScheme.onSecondary, ), SlidableAction( - onPressed: (_) async { - try { - await onTabClosed(tab.id); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.couldNotCloseTab)), - ); - } - } - }, - icon: Icons.close_rounded, - label: l10n.close, + onPressed: (_) => onTabDeleted(tab), + icon: Icons.delete_outline_rounded, + label: l10n.delete, backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), diff --git a/lib/views/dialogs/edit_tab_dialog.dart b/lib/views/dialogs/edit_tab_dialog.dart new file mode 100644 index 0000000..bd11f2f --- /dev/null +++ b/lib/views/dialogs/edit_tab_dialog.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/bar_tab.dart'; +import '../../viewmodels/bar_screen_view_model.dart'; + +Future showEditTabDialog(BuildContext context, BarTab tab) async { + final l10n = AppLocalizations.of(context); + + final name = await showDialog( + context: context, + builder: (_) => _EditTabDialog(initialName: tab.customerName), + ); + + if (name == null || name.trim().isEmpty || !context.mounted) return; + + try { + await context.read().renameTab(tab.id, name.trim()); + } catch (e, stack) { + Sentry.captureException(e, stackTrace: stack); + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotRenameTab))); + } +} + +class _EditTabDialog extends StatefulWidget { + final String initialName; + + const _EditTabDialog({required this.initialName}); + + @override + State<_EditTabDialog> createState() => _EditTabDialogState(); +} + +class _EditTabDialogState extends State<_EditTabDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialName); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return AlertDialog( + title: Text(l10n.editTab), + content: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration(labelText: l10n.customerGroupName), + onSubmitted: (value) { + Navigator.of(context).pop(value); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_controller.text), + child: Text(l10n.confirm), + ), + ], + ); + } +} + +Future confirmDeleteTab(BuildContext context, BarTab tab) async { + final l10n = AppLocalizations.of(context); + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.deleteTab), + content: Text(l10n.deleteTabDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.delete), + ), + ], + ); + }, + ); + + if (confirmed != true || !context.mounted) return; + + try { + await context.read().deleteTab(tab.id); + } catch (e, stack) { + Sentry.captureException(e, stackTrace: stack); + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotDeleteTab))); + } +} diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 396f707..d8e3a2d 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -241,6 +241,14 @@ class _SettingsScreenViewState extends State { } }, ), + _SettingsSwitchTile( + icon: Icons.lock_clock_rounded, + title: l10n.autoLockOnExit, + value: settings.autoLockEnabled, + onChanged: (value) { + settingsViewModel.updateAutoLockEnabled(value); + }, + ), if (settings.pinRequired) _SettingsTile( icon: Icons.pin_rounded, diff --git a/test/app_lifecycle_lock_test.dart b/test/app_lifecycle_lock_test.dart index 526878a..365e3a1 100644 --- a/test/app_lifecycle_lock_test.dart +++ b/test/app_lifecycle_lock_test.dart @@ -27,4 +27,23 @@ void main() { expect(lockCount, 0); }); + + test('does not lock when auto-lock is disabled', () { + var lockCount = 0; + final observer = AppLifecycleLockObserver( + onLock: () => lockCount++, + shouldLock: () => false, + ); + + for (final state in [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.detached, + ]) { + observer.didChangeAppLifecycleState(state); + } + + expect(lockCount, 0); + }); } diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index 84f6428..4f2ec1b 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/models/product.dart'; import 'package:kooltab2/models/tab_item.dart'; import 'package:kooltab2/services/bar_tab_service.dart'; import 'package:kooltab2/services/product_service.dart'; @@ -50,4 +51,36 @@ void main() { () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), ).called(1); }); + + test('updates inventory when a tab is deleted', () async { + final product = Product( + id: 'prod-1', + name: 'Chips', + category: 'Snacks', + stockQuantity: 8, + lowStockThreshold: 2, + priceInCents: 150, + ); + const item = TabItem( + id: 'item-1', + tabId: 'tab-1', + productId: 'prod-1', + productName: 'Chips', + quantity: 3, + unitPriceInCents: 150, + ); + + when(() => productService.getProducts()).thenAnswer((_) async => [product]); + await inventory.load(); + when( + () => barTabService.deleteTab('tab-1'), + ).thenAnswer((_) async => [item]); + when(() => barTabService.getOpenTabs()).thenAnswer((_) async => []); + + await viewModel.deleteTab('tab-1'); + + expect(inventory.products.first.stockQuantity, 11); + verify(() => barTabService.deleteTab('tab-1')).called(1); + verify(() => barTabService.getOpenTabs()).called(1); + }); } diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index 12ee046..5e9a646 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -136,6 +136,40 @@ void main() { }); }); + group('renameTab', () { + test('updates the tab customer name', () async { + final tab = await service.createTab(customerName: 'John'); + + await service.renameTab(tabId: tab.id, customerName: ' Jane '); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.customerName, 'Jane'); + }); + }); + + group('deleteTab', () { + test('deletes the tab and restores item stock', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await service.addProductToTab(tabId: tab.id, product: product); + await service.addProductToTab(tabId: tab.id, product: product); + + final deletedItems = await service.deleteTab(tab.id); + + expect(deletedItems.length, 1); + expect(deletedItems.first.quantity, 2); + expect(await service.getTabById(tab.id), isNull); + + final updatedProduct = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingle(); + expect(updatedProduct.stockQuantity, 100); + + final remainingItems = await database.select(database.tabItems).get(); + expect(remainingItems, isEmpty); + }); + }); + group('addProductToTab', () { test('adds new product to tab', () async { final tab = await service.createTab(customerName: 'John'); diff --git a/test/edit_tab_dialog_test.dart b/test/edit_tab_dialog_test.dart new file mode 100644 index 0000000..f45b6d3 --- /dev/null +++ b/test/edit_tab_dialog_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/l10n/app_localizations.dart'; +import 'package:kooltab2/models/bar_tab.dart'; +import 'package:kooltab2/services/bar_tab_service.dart'; +import 'package:kooltab2/services/product_service.dart'; +import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltab2/viewmodels/inventory_view_model.dart'; +import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; + +class MockBarTabService extends Mock implements BarTabService {} + +class MockProductService extends Mock implements ProductService {} + +void main() { + testWidgets('edit dialog disposes its controller after closing', ( + tester, + ) async { + final barTabService = MockBarTabService(); + final productService = MockProductService(); + final viewModel = BarScreenViewModel( + barTabService: barTabService, + inventory: InventoryViewModel(productService: productService), + ); + final tab = BarTab( + id: 'tab-1', + customerName: 'Before', + status: 'open', + openedAt: DateTime(2026), + items: const [], + ); + + when( + () => barTabService.renameTab(tabId: 'tab-1', customerName: 'After'), + ).thenAnswer((_) async {}); + when(() => barTabService.getOpenTabs()).thenAnswer((_) async => [tab]); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: viewModel, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showEditTabDialog(context, tab), + child: const Text('Edit'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'After'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + verify( + () => barTabService.renameTab(tabId: 'tab-1', customerName: 'After'), + ).called(1); + }); +} diff --git a/test/settings_service_test.dart b/test/settings_service_test.dart new file mode 100644 index 0000000..a5eaa06 --- /dev/null +++ b/test/settings_service_test.dart @@ -0,0 +1,36 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/database/app_database.dart'; +import 'package:kooltab2/models/settings.dart'; +import 'package:kooltab2/services/settings_service.dart'; + +void main() { + late AppDatabase database; + late DriftSettingsService service; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + service = DriftSettingsService(database: database); + }); + + tearDown(() async { + await database.close(); + }); + + test('defaults auto-lock to enabled', () async { + final settings = await service.getSettings(); + + expect(settings, AppSettings.defaults); + expect(settings.autoLockEnabled, isTrue); + }); + + test('persists the auto-lock setting', () async { + await service.saveSettings( + AppSettings.defaults.copyWith(autoLockEnabled: false), + ); + + final settings = await service.getSettings(); + + expect(settings.autoLockEnabled, isFalse); + }); +}