diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart index bd35c09..71e0653 100644 --- a/lib/app/app_bootstrap.dart +++ b/lib/app/app_bootstrap.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter/services.dart'; import '../database/app_database.dart'; import '../services/default_product_seeder.dart'; @@ -31,6 +32,9 @@ class _AppBootstrapState extends State { context.read().ensureLoaded(); DefaultExportService(database: database).uploadToServer(); + + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); }); } diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 66a8ff8..8e37ea0 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -2,7 +2,6 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:path_provider/path_provider.dart'; - part 'app_database.g.dart'; @DataClassName('ProductRow') @@ -111,6 +110,8 @@ class AppSettingsTable extends Table { TextColumn get language => text().withDefault(const Constant('system'))(); + IntColumn get barGridRows => integer().withDefault(const Constant(3))(); + @override Set get primaryKey => {id}; } @@ -131,7 +132,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration { @@ -140,6 +141,14 @@ class AppDatabase extends _$AppDatabase { await migrator.createAll(); }, onUpgrade: (migrator, from, to) async { + Future hasSettingsColumn(String columnName) async { + final columns = await migrator.database + .customSelect('PRAGMA table_info(app_settings)') + .get(); + + return columns.any((column) => column.data['name'] == columnName); + } + if (from < 2) { await migrator.createTable(barTabs); await migrator.createTable(tabItems); @@ -162,9 +171,16 @@ class AppDatabase extends _$AppDatabase { await migrator.addColumn(closedTabs, closedTabs.paymentMethod); } - if (from < 7) { + if (from < 7 && !await hasSettingsColumn('language')) { await migrator.addColumn(appSettingsTable, appSettingsTable.language); } + + if (from < 8 && !await hasSettingsColumn('bar_grid_rows')) { + await migrator.addColumn( + appSettingsTable, + appSettingsTable.barGridRows, + ); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index de3a94b..8438ad9 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2231,8 +2231,26 @@ class $AppSettingsTableTable extends AppSettingsTable requiredDuringInsert: false, defaultValue: const Constant('system'), ); + static const VerificationMeta _barGridRowsMeta = const VerificationMeta( + 'barGridRows', + ); @override - List get $columns => [id, pinRequired, themeMode, language]; + late final GeneratedColumn barGridRows = GeneratedColumn( + 'bar_grid_rows', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(3), + ); + @override + List get $columns => [ + id, + pinRequired, + themeMode, + language, + barGridRows, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -2271,6 +2289,15 @@ class $AppSettingsTableTable extends AppSettingsTable language.isAcceptableOrUnknown(data['language']!, _languageMeta), ); } + if (data.containsKey('bar_grid_rows')) { + context.handle( + _barGridRowsMeta, + barGridRows.isAcceptableOrUnknown( + data['bar_grid_rows']!, + _barGridRowsMeta, + ), + ); + } return context; } @@ -2296,6 +2323,10 @@ class $AppSettingsTableTable extends AppSettingsTable DriftSqlType.string, data['${effectivePrefix}language'], )!, + barGridRows: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bar_grid_rows'], + )!, ); } @@ -2310,11 +2341,13 @@ class AppSettingsRow extends DataClass implements Insertable { final bool pinRequired; final String themeMode; final String language; + final int barGridRows; const AppSettingsRow({ required this.id, required this.pinRequired, required this.themeMode, required this.language, + required this.barGridRows, }); @override Map toColumns(bool nullToAbsent) { @@ -2323,6 +2356,7 @@ class AppSettingsRow extends DataClass implements Insertable { map['pin_required'] = Variable(pinRequired); map['theme_mode'] = Variable(themeMode); map['language'] = Variable(language); + map['bar_grid_rows'] = Variable(barGridRows); return map; } @@ -2332,6 +2366,7 @@ class AppSettingsRow extends DataClass implements Insertable { pinRequired: Value(pinRequired), themeMode: Value(themeMode), language: Value(language), + barGridRows: Value(barGridRows), ); } @@ -2345,6 +2380,7 @@ class AppSettingsRow extends DataClass implements Insertable { pinRequired: serializer.fromJson(json['pinRequired']), themeMode: serializer.fromJson(json['themeMode']), language: serializer.fromJson(json['language']), + barGridRows: serializer.fromJson(json['barGridRows']), ); } @override @@ -2355,6 +2391,7 @@ class AppSettingsRow extends DataClass implements Insertable { 'pinRequired': serializer.toJson(pinRequired), 'themeMode': serializer.toJson(themeMode), 'language': serializer.toJson(language), + 'barGridRows': serializer.toJson(barGridRows), }; } @@ -2363,11 +2400,13 @@ class AppSettingsRow extends DataClass implements Insertable { bool? pinRequired, String? themeMode, String? language, + int? barGridRows, }) => AppSettingsRow( id: id ?? this.id, pinRequired: pinRequired ?? this.pinRequired, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, + barGridRows: barGridRows ?? this.barGridRows, ); AppSettingsRow copyWithCompanion(AppSettingsTableCompanion data) { return AppSettingsRow( @@ -2377,6 +2416,9 @@ class AppSettingsRow extends DataClass implements Insertable { : this.pinRequired, themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, language: data.language.present ? data.language.value : this.language, + barGridRows: data.barGridRows.present + ? data.barGridRows.value + : this.barGridRows, ); } @@ -2386,13 +2428,15 @@ class AppSettingsRow extends DataClass implements Insertable { ..write('id: $id, ') ..write('pinRequired: $pinRequired, ') ..write('themeMode: $themeMode, ') - ..write('language: $language') + ..write('language: $language, ') + ..write('barGridRows: $barGridRows') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, pinRequired, themeMode, language); + int get hashCode => + Object.hash(id, pinRequired, themeMode, language, barGridRows); @override bool operator ==(Object other) => identical(this, other) || @@ -2400,7 +2444,8 @@ class AppSettingsRow extends DataClass implements Insertable { other.id == this.id && other.pinRequired == this.pinRequired && other.themeMode == this.themeMode && - other.language == this.language); + other.language == this.language && + other.barGridRows == this.barGridRows); } class AppSettingsTableCompanion extends UpdateCompanion { @@ -2408,12 +2453,14 @@ class AppSettingsTableCompanion extends UpdateCompanion { final Value pinRequired; final Value themeMode; final Value language; + final Value barGridRows; final Value rowid; const AppSettingsTableCompanion({ this.id = const Value.absent(), this.pinRequired = const Value.absent(), this.themeMode = const Value.absent(), this.language = const Value.absent(), + this.barGridRows = const Value.absent(), this.rowid = const Value.absent(), }); AppSettingsTableCompanion.insert({ @@ -2421,6 +2468,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { this.pinRequired = const Value.absent(), this.themeMode = const Value.absent(), this.language = const Value.absent(), + this.barGridRows = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id); static Insertable custom({ @@ -2428,6 +2476,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { Expression? pinRequired, Expression? themeMode, Expression? language, + Expression? barGridRows, Expression? rowid, }) { return RawValuesInsertable({ @@ -2435,6 +2484,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { if (pinRequired != null) 'pin_required': pinRequired, if (themeMode != null) 'theme_mode': themeMode, if (language != null) 'language': language, + if (barGridRows != null) 'bar_grid_rows': barGridRows, if (rowid != null) 'rowid': rowid, }); } @@ -2444,6 +2494,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { Value? pinRequired, Value? themeMode, Value? language, + Value? barGridRows, Value? rowid, }) { return AppSettingsTableCompanion( @@ -2451,6 +2502,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { pinRequired: pinRequired ?? this.pinRequired, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, + barGridRows: barGridRows ?? this.barGridRows, rowid: rowid ?? this.rowid, ); } @@ -2470,6 +2522,9 @@ class AppSettingsTableCompanion extends UpdateCompanion { if (language.present) { map['language'] = Variable(language.value); } + if (barGridRows.present) { + map['bar_grid_rows'] = Variable(barGridRows.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2483,6 +2538,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { ..write('pinRequired: $pinRequired, ') ..write('themeMode: $themeMode, ') ..write('language: $language, ') + ..write('barGridRows: $barGridRows, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4071,6 +4127,7 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder = Value pinRequired, Value themeMode, Value language, + Value barGridRows, Value rowid, }); typedef $$AppSettingsTableTableUpdateCompanionBuilder = @@ -4079,6 +4136,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder = Value pinRequired, Value themeMode, Value language, + Value barGridRows, Value rowid, }); @@ -4110,6 +4168,11 @@ class $$AppSettingsTableTableFilterComposer column: $table.language, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get barGridRows => $composableBuilder( + column: $table.barGridRows, + builder: (column) => ColumnFilters(column), + ); } class $$AppSettingsTableTableOrderingComposer @@ -4140,6 +4203,11 @@ class $$AppSettingsTableTableOrderingComposer column: $table.language, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get barGridRows => $composableBuilder( + column: $table.barGridRows, + builder: (column) => ColumnOrderings(column), + ); } class $$AppSettingsTableTableAnnotationComposer @@ -4164,6 +4232,11 @@ class $$AppSettingsTableTableAnnotationComposer GeneratedColumn get language => $composableBuilder(column: $table.language, builder: (column) => column); + + GeneratedColumn get barGridRows => $composableBuilder( + column: $table.barGridRows, + builder: (column) => column, + ); } class $$AppSettingsTableTableTableManager @@ -4207,12 +4280,14 @@ class $$AppSettingsTableTableTableManager Value pinRequired = const Value.absent(), Value themeMode = const Value.absent(), Value language = const Value.absent(), + Value barGridRows = const Value.absent(), Value rowid = const Value.absent(), }) => AppSettingsTableCompanion( id: id, pinRequired: pinRequired, themeMode: themeMode, language: language, + barGridRows: barGridRows, rowid: rowid, ), createCompanionCallback: @@ -4221,12 +4296,14 @@ class $$AppSettingsTableTableTableManager Value pinRequired = const Value.absent(), Value themeMode = const Value.absent(), Value language = const Value.absent(), + Value barGridRows = const Value.absent(), Value rowid = const Value.absent(), }) => AppSettingsTableCompanion.insert( id: id, pinRequired: pinRequired, themeMode: themeMode, language: language, + barGridRows: barGridRows, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e8fbd5d..239ea31 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -31,6 +31,9 @@ "languageSystem": "System default", "languageEnglish": "English", "languageDutch": "Dutch", + "productGridRows": "Product grid rows", + "rowsCount": "{count} rows", + "@rowsCount": {"placeholders": {"count": {"type": "int"}}}, "theme": "Theme", "system": "System", "light": "Light", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6b4d8a5..3e82184 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -278,6 +278,18 @@ abstract class AppLocalizations { /// **'Dutch'** String get languageDutch; + /// No description provided for @productGridRows. + /// + /// In en, this message translates to: + /// **'Product grid rows'** + String get productGridRows; + + /// No description provided for @rowsCount. + /// + /// In en, this message translates to: + /// **'{count} rows'** + String rowsCount(int count); + /// No description provided for @theme. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 34ab41f..801db9f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -100,6 +100,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get languageDutch => 'Dutch'; + @override + String get productGridRows => 'Product grid rows'; + + @override + String rowsCount(int count) { + return '$count rows'; + } + @override String get theme => 'Theme'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 3c989b8..70e5c0f 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -100,6 +100,14 @@ class AppLocalizationsNl extends AppLocalizations { @override String get languageDutch => 'Nederlands'; + @override + String get productGridRows => 'Rijen in productraster'; + + @override + String rowsCount(int count) { + return '$count rijen'; + } + @override String get theme => 'Thema'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 69b887a..e306d02 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -31,6 +31,9 @@ "languageSystem": "Systeemstandaard", "languageEnglish": "Engels", "languageDutch": "Nederlands", + "productGridRows": "Rijen in productraster", + "rowsCount": "{count} rijen", + "@rowsCount": {"placeholders": {"count": {"type": "int"}}}, "theme": "Thema", "system": "Systeem", "light": "Licht", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index d36ed6f..6d9a23d 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,26 +1,31 @@ enum AppThemeMode { system, light, dark, ugly } + enum AppLanguage { system, english, dutch } class AppSettings { final bool pinRequired; final AppThemeMode themeMode; final AppLanguage language; + final int barGridRows; const AppSettings({ required this.pinRequired, required this.themeMode, this.language = AppLanguage.system, + this.barGridRows = 3, }); AppSettings copyWith({ bool? pinRequired, AppThemeMode? themeMode, AppLanguage? language, + int? barGridRows, }) { return AppSettings( pinRequired: pinRequired ?? this.pinRequired, themeMode: themeMode ?? this.themeMode, language: language ?? this.language, + barGridRows: barGridRows ?? this.barGridRows, ); } @@ -35,8 +40,10 @@ class AppSettings { other is AppSettings && pinRequired == other.pinRequired && themeMode == other.themeMode && - language == other.language; + language == other.language && + barGridRows == other.barGridRows; @override - int get hashCode => Object.hash(pinRequired, themeMode, language); + int get hashCode => + Object.hash(pinRequired, themeMode, language, barGridRows); } diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 87dcb67..26fa8a0 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; import '../models/product.dart'; +class InsufficientStockException implements Exception { + const InsufficientStockException(); + + @override + String toString() => 'Not enough stock'; +} + abstract class ProductService { Future> getProducts(); @@ -130,7 +137,7 @@ class DriftProductService implements ProductService { } if (product.stockQuantity < amount) { - throw Exception('Not enough stock'); + throw const InsufficientStockException(); } await updateProduct( diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 291015b..067af4d 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -26,6 +26,7 @@ class DriftSettingsService implements SettingsService { (language) => language.name == row.language, orElse: () => AppLanguage.system, ), + barGridRows: row.barGridRows.clamp(2, 6).toInt(), ); } @@ -51,6 +52,7 @@ class DriftSettingsService implements SettingsService { pinRequired: Value(settings.pinRequired), 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 b248ee2..8746898 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -6,6 +6,7 @@ import '../models/bar_tab.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; import '../services/bar_tab_service.dart'; +import '../services/product_service.dart'; import 'inventory_view_model.dart'; class BarScreenViewModel extends ChangeNotifier { @@ -95,9 +96,7 @@ class BarScreenViewModel extends ChangeNotifier { if (tab == null) return; if (product.stockQuantity <= 0) { - _errorMessage = 'Product is out of stock.'; - notifyListeners(); - return; + throw const InsufficientStockException(); } try { @@ -109,6 +108,8 @@ class BarScreenViewModel extends ChangeNotifier { await _reloadTabs(); } catch (e, stack) { + if (e is InsufficientStockException) rethrow; + debugPrint('BarScreenViewModel: addProductToSelectedTab error: $e'); _errorMessage = 'Could not add product to tab.'; notifyListeners(); @@ -120,6 +121,20 @@ class BarScreenViewModel extends ChangeNotifier { Future changeItemQuantity(TabItem item, int quantity) async { final difference = quantity - item.quantity; + if (difference > 0) { + Product? product; + for (final candidate in inventory.products) { + if (candidate.id == item.productId) { + product = candidate; + break; + } + } + + if (product != null && product.stockQuantity < difference) { + throw const InsufficientStockException(); + } + } + try { await barTabService.updateTabItemQuantity( tabItemId: item.id, @@ -135,6 +150,8 @@ class BarScreenViewModel extends ChangeNotifier { await _reloadTabs(); } catch (e, stack) { + if (e is InsufficientStockException) rethrow; + debugPrint('BarScreenViewModel: changeItemQuantity error: $e'); _errorMessage = 'Could not update item quantity.'; notifyListeners(); diff --git a/lib/viewmodels/inventory_view_model.dart b/lib/viewmodels/inventory_view_model.dart index 0857d26..655d138 100644 --- a/lib/viewmodels/inventory_view_model.dart +++ b/lib/viewmodels/inventory_view_model.dart @@ -38,6 +38,8 @@ class InventoryViewModel extends ChangeNotifier { notifyListeners(); } catch (e, stack) { + if (e is InsufficientStockException) rethrow; + debugPrint('InventoryViewModel: decreaseStock error: $e'); Sentry.captureException(e, stackTrace: stack); rethrow; diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart index 05239e9..2e62d82 100644 --- a/lib/viewmodels/settings_view_model.dart +++ b/lib/viewmodels/settings_view_model.dart @@ -114,6 +114,9 @@ class SettingsViewModel extends ChangeNotifier { Future updateLanguage(AppLanguage language) => _save(_settings.copyWith(language: language)); + Future updateBarGridRows(int rows) => + _save(_settings.copyWith(barGridRows: rows.clamp(2, 6).toInt())); + Future _save(AppSettings updated) async { final previous = _settings; _settings = updated; diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 0e03357..9ec86a8 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -12,8 +12,10 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../models/bar_tab.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import '../services/product_service.dart'; import '../utils/app_updater.dart'; import '../viewmodels/bar_screen_view_model.dart'; +import '../viewmodels/settings_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; @@ -38,6 +40,11 @@ class _BarScreenViewState extends State { final l10n = AppLocalizations.of(context); final viewModel = context.watch(); final productsViewModel = context.watch(); + final settings = context.watch().settings; + final stockByProductId = { + for (final product in productsViewModel.products) + product.id: product.stockQuantity, + }; return Scaffold( appBar: AppBar( @@ -118,6 +125,7 @@ class _BarScreenViewState extends State { flex: 2, child: _ProductGrid( products: productsViewModel.products, + rows: settings.barGridRows, hasSelectedTab: viewModel.selectedTab != null, onProductTap: (product) async { if (viewModel.selectedTab == null) { @@ -130,10 +138,18 @@ class _BarScreenViewState extends State { try { await viewModel.addProductToSelectedTab(product); } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); + if (e is! InsufficientStockException) { + Sentry.captureException(e, stackTrace: stack); + } if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.couldNotAddProductToTab)), + SnackBar( + content: Text( + e is InsufficientStockException + ? l10n.productOutOfStock + : l10n.couldNotAddProductToTab, + ), + ), ); } }, @@ -146,6 +162,7 @@ class _BarScreenViewState extends State { tabs: viewModel.tabs, selectedTab: viewModel.selectedTab, selectedTabId: viewModel.selectedTabId, + stockByProductId: stockByProductId, onNewTabPressed: () => showNewTabDialog(context), onTabSelected: viewModel.selectTab, onItemQuantityChanged: viewModel.changeItemQuantity, @@ -166,11 +183,13 @@ class _BarScreenViewState extends State { // --------------------------------------------------------------------------- class _ProductGrid extends StatelessWidget { final List products; + final int rows; final bool hasSelectedTab; final ValueChanged onProductTap; const _ProductGrid({ required this.products, + required this.rows, required this.hasSelectedTab, required this.onProductTap, }); @@ -267,11 +286,26 @@ class _ProductGrid extends StatelessWidget { Expanded( child: LayoutBuilder( builder: (context, constraints) { - final columns = ((constraints.maxWidth / 170).floor()) - .clamp(3, 5) - .toInt(); + const horizontalPadding = 32.0; + const verticalPadding = 28.0; + const spacing = 12.0; + final rowCount = rows.clamp(2, 6).toInt(); + final rowHeight = + ((constraints.maxHeight - + verticalPadding - + (rowCount - 1) * spacing) / + rowCount) + .clamp(96.0, 320.0) + .toDouble(); + final columns = + (((constraints.maxWidth - horizontalPadding + spacing) / + (rowHeight + spacing)) + .ceil()) + .clamp(2, 8) + .toInt(); return GridView.builder( + key: ValueKey('product-grid-$rows'), padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), itemCount: products.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -306,6 +340,7 @@ class _TabPanel extends StatefulWidget { final List tabs; final BarTab? selectedTab; final String? selectedTabId; + final Map stockByProductId; final VoidCallback onNewTabPressed; final ValueChanged onTabSelected; final Future Function(TabItem item, int quantity) onItemQuantityChanged; @@ -316,6 +351,7 @@ class _TabPanel extends StatefulWidget { required this.tabs, required this.selectedTab, required this.selectedTabId, + required this.stockByProductId, required this.onNewTabPressed, required this.onTabSelected, required this.onItemQuantityChanged, @@ -452,6 +488,7 @@ class _TabPanelState extends State<_TabPanel> { ) : _SelectedTabDetails( tab: widget.selectedTab!, + stockByProductId: widget.stockByProductId, onItemQuantityChanged: widget.onItemQuantityChanged, onCloseTabPressed: widget.onCloseTabPressed, ), @@ -596,11 +633,13 @@ class _OpenTabsList extends StatelessWidget { class _SelectedTabDetails extends StatelessWidget { final BarTab tab; + final Map stockByProductId; final Future Function(TabItem item, int quantity) onItemQuantityChanged; final VoidCallback onCloseTabPressed; const _SelectedTabDetails({ required this.tab, + required this.stockByProductId, required this.onItemQuantityChanged, required this.onCloseTabPressed, }); @@ -654,6 +693,7 @@ class _SelectedTabDetails extends StatelessWidget { return _TabItemRow( item: item, + stockByProductId: stockByProductId, onQuantityChanged: onItemQuantityChanged, ); }, @@ -706,14 +746,21 @@ class _SelectedTabDetails extends StatelessWidget { class _TabItemRow extends StatelessWidget { final TabItem item; + final Map stockByProductId; final Future Function(TabItem item, int quantity) onQuantityChanged; - const _TabItemRow({required this.item, required this.onQuantityChanged}); + const _TabItemRow({ + required this.item, + required this.stockByProductId, + required this.onQuantityChanged, + }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context); + final availableStock = stockByProductId[item.productId]; + final canIncrease = availableStock == null || availableStock > 0; return Padding( padding: const EdgeInsets.symmetric(vertical: 10), @@ -771,18 +818,28 @@ class _TabItemRow extends StatelessWidget { ), IconButton( visualDensity: VisualDensity.compact, - onPressed: () async { - try { - await onQuantityChanged(item, item.quantity + 1); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.couldNotUpdateQuantity)), - ); - } - } - }, + onPressed: canIncrease + ? () async { + try { + await onQuantityChanged(item, item.quantity + 1); + } catch (e, stack) { + if (e is! InsufficientStockException) { + Sentry.captureException(e, stackTrace: stack); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is InsufficientStockException + ? l10n.productOutOfStock + : l10n.couldNotUpdateQuantity, + ), + ), + ); + } + } + } + : null, icon: const Icon(Icons.add_rounded, size: 18), ), ], diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 262bf86..a145309 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -22,7 +23,7 @@ class SettingsScreenView extends StatefulWidget { class _SettingsScreenViewState extends State { String _appVersion = ''; - int _versionTaps = 0; + Timer? _versionHoldTimer; @override void initState() { @@ -41,13 +42,23 @@ class _SettingsScreenViewState extends State { return info.version; } - void _onVersionTap() { - _versionTaps++; + void _startVersionHold() { + _versionHoldTimer?.cancel(); + _versionHoldTimer = Timer(const Duration(seconds: 3), () { + _versionHoldTimer = null; + if (mounted) context.push('/dev'); + }); + } - if (_versionTaps >= 7) { - _versionTaps = 0; - context.push('/dev'); - } + void _cancelVersionHold() { + _versionHoldTimer?.cancel(); + _versionHoldTimer = null; + } + + @override + void dispose() { + _cancelVersionHold(); + super.dispose(); } Future _loadVersion() async { @@ -277,6 +288,35 @@ class _SettingsScreenViewState extends State { } }, ), + _SettingsTile( + icon: Icons.grid_view_rounded, + title: l10n.productGridRows, + subtitle: l10n.rowsCount(settings.barGridRows), + onTap: () async { + final selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final rows in [2, 3, 4, 5, 6]) + RadioListTile( + value: rows, + groupValue: settings.barGridRows, + title: Text(l10n.rowsCount(rows)), + onChanged: (value) => + Navigator.pop(context, value), + ), + ], + ), + ), + ); + + if (selected != null) { + await settingsViewModel.updateBarGridRows(selected); + } + }, + ), _SettingsTile( icon: Icons.brightness_6_rounded, title: l10n.theme, @@ -334,7 +374,9 @@ class _SettingsScreenViewState extends State { ), const SizedBox(height: 40), GestureDetector( - onTap: _onVersionTap, + onTapDown: (_) => _startVersionHold(), + onTapUp: (_) => _cancelVersionHold(), + onTapCancel: _cancelVersionHold, child: Center( child: Text( l10n.version(_appVersion), diff --git a/pubspec.yaml b/pubspec.yaml index 728caf9..3807522 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.7+1 +version: 1.0.9+1 environment: sdk: ^3.12.2 diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart new file mode 100644 index 0000000..75817ea --- /dev/null +++ b/test/bar_screen_view_model_test.dart @@ -0,0 +1,64 @@ +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'; +import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltab2/viewmodels/inventory_view_model.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBarTabService extends Mock implements BarTabService {} + +class MockProductService extends Mock implements ProductService {} + +void main() { + late MockBarTabService barTabService; + late MockProductService productService; + late InventoryViewModel inventory; + late BarScreenViewModel viewModel; + + setUp(() { + barTabService = MockBarTabService(); + productService = MockProductService(); + inventory = InventoryViewModel(productService: productService); + viewModel = BarScreenViewModel( + barTabService: barTabService, + inventory: inventory, + ); + }); + + test( + 'does not increase a tab item when its product is out of stock', + () async { + const product = Product( + id: 'prod-1', + name: 'Chips', + category: 'Snacks', + stockQuantity: 0, + lowStockThreshold: 5, + priceInCents: 150, + ); + const item = TabItem( + id: 'item-1', + tabId: 'tab-1', + productId: 'prod-1', + productName: 'Chips', + quantity: 20, + unitPriceInCents: 150, + ); + + when( + () => productService.getProducts(), + ).thenAnswer((_) async => [product]); + await inventory.load(); + + await expectLater( + viewModel.changeItemQuantity(item, 21), + throwsA(isA()), + ); + + expect(viewModel.errorMessage, isNull); + verifyNoMoreInteractions(barTabService); + }, + ); +} diff --git a/test/product_service_test.dart b/test/product_service_test.dart index ec2637a..c872b48 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -218,7 +218,7 @@ void main() { expect( () => service.decreaseStock('low-stock', 5), - throwsA(isA()), + throwsA(isA()), ); }); });