diff --git a/assets/products/cola.webp b/assets/products/cola.webp new file mode 100644 index 0000000..068f9d8 Binary files /dev/null and b/assets/products/cola.webp differ diff --git a/assets/products/cola_zero.jpg b/assets/products/cola_zero.jpg new file mode 100644 index 0000000..e7956ce Binary files /dev/null and b/assets/products/cola_zero.jpg differ diff --git a/assets/products/jupiler.jpg b/assets/products/jupiler.jpg new file mode 100644 index 0000000..cfeeb24 Binary files /dev/null and b/assets/products/jupiler.jpg differ diff --git a/assets/products/stella_artois.png b/assets/products/stella_artois.png new file mode 100644 index 0000000..5b6f413 Binary files /dev/null and b/assets/products/stella_artois.png differ diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart index 6eaebdd..9c7ec40 100644 --- a/lib/app/app_bootstrap.dart +++ b/lib/app/app_bootstrap.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../database/app_database.dart'; +import '../services/default_product_seeder.dart'; import '../viewmodels/bar_screen_view_model.dart'; import '../viewmodels/product_list_view_model.dart'; @@ -21,6 +23,9 @@ class _AppBootstrapState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + final database = context.read(); + DefaultProductSeeder(database: database).seedIfEmpty(); + context.read().ensureLoaded(); context.read().ensureLoaded(); }); diff --git a/lib/services/default_product_seeder.dart b/lib/services/default_product_seeder.dart new file mode 100644 index 0000000..b6c5a45 --- /dev/null +++ b/lib/services/default_product_seeder.dart @@ -0,0 +1,156 @@ + import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:drift/drift.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uuid/uuid.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../database/app_database.dart'; + +class DefaultProductSeeder { + final AppDatabase database; + + DefaultProductSeeder({required this.database}); + + static const _defaultProducts = [ + ('Jupiler', 280, 80, 'Bier'), + ('Stella Artois', 300, 60, 'Bier'), + ('Leffe Blonde', 380, 30, 'Bier'), + ('Duvel', 420, 25, 'Bier'), + ('Hoegaarden', 350, 35, 'Bier'), + ('Chimay Blue', 500, 20, 'Bier'), + ('Witte Wijn', 380, 50, 'Wijn'), + ('Rode Wijn', 400, 45, 'Wijn'), + ('Rosé', 350, 40, 'Wijn'), + ('Cava', 650, 15, 'Wijn'), + ('Cola', 250, 100, 'Frisdrank'), + ('Cola Zero', 250, 80, 'Frisdrank'), + ('Spa Bruis', 200, 70, 'Frisdrank'), + ('Spa Plat', 200, 70, 'Frisdrank'), + ('Ice Tea', 280, 60, 'Frisdrank'), + ('Limonade', 250, 50, 'Frisdrank'), + ('Chips', 220, 40, 'Snacks'), + ('Nootjes', 280, 35, 'Snacks'), + ('Kroketten', 350, 30, 'Snacks'), + ('Bitterballen', 320, 30, 'Snacks'), + ('Koffie', 280, 200, 'Coffee'), + ('Espresso', 250, 200, 'Coffee'), + ('Latte Macchiato', 350, 150, 'Coffee'), + ('Warme Chocomelk', 320, 100, 'Coffee'), + ('Thee', 250, 150, 'Coffee'), + ]; + + Future seedIfEmpty() async { + try { + final count = await _getProductCount(); + if (count > 0) return; + + final imagesDir = await _ensureImagesDir(); + + await database.transaction(() async { + final uuid = const Uuid(); + + for (final (name, priceInCents, stock, category) in _defaultProducts) { + String? imagePath; + + if (imagesDir != null) { + imagePath = await _copyBundledImage(name, imagesDir); + } + + await database.into(database.products).insert( + ProductsCompanion.insert( + id: uuid.v4(), + name: name, + category: category, + stockQuantity: stock, + lowStockThreshold: 10, + priceInCents: priceInCents, + imagePath: Value(imagePath), + ), + ); + } + }); + } catch (e, stack) { + debugPrint('DefaultProductSeeder: seedIfEmpty error: $e'); + Sentry.captureException(e, stackTrace: stack); + } + } + + Future _getProductCount() async { + final countExpr = database.products.id.count(); + final query = database.selectOnly(database.products) + ..addColumns([countExpr]); + final row = await query.getSingle(); + return row.read(countExpr) ?? 0; + } + + Future _ensureImagesDir() async { + try { + final appDir = await getApplicationSupportDirectory(); + final imagesDir = Directory(path.join(appDir.path, 'product_images')); + + if (!imagesDir.existsSync()) { + await imagesDir.create(recursive: true); + } + + return imagesDir; + } catch (e) { + debugPrint('DefaultProductSeeder: could not create images dir: $e'); + return null; + } + } + + Future _copyBundledImage(String productName, Directory imagesDir) async { + final slug = _toSlug(productName); + + for (final ext in ['png', 'jpg', 'webp']) { + final assetKey = 'assets/products/$slug.$ext'; + + try { + final bytes = await rootBundle.load(assetKey); + final fileName = '${DateTime.now().millisecondsSinceEpoch}_$slug.$ext'; + final destPath = path.join(imagesDir.path, fileName); + final destFile = File(destPath); + + await destFile.writeAsBytes(bytes.buffer.asUint8List( + bytes.offsetInBytes, + bytes.lengthInBytes, + )); + + return destPath; + } catch (_) { + // Asset not found for this extension, try next + } + } + + debugPrint('DefaultProductSeeder: no bundled image found for "$productName" ' + '(looked for assets/products/$slug.{png,jpg,webp})'); + return null; + } + + String _toSlug(String name) { + return name + .toLowerCase() + .replaceAll(' ', '_') + .replaceAll("'", '') + .replaceAll('é', 'e') + .replaceAll('è', 'e') + .replaceAll('ê', 'e') + .replaceAll('ë', 'e') + .replaceAll('â', 'a') + .replaceAll('à', 'a') + .replaceAll('ä', 'a') + .replaceAll('û', 'u') + .replaceAll('ù', 'u') + .replaceAll('ü', 'u') + .replaceAll('ô', 'o') + .replaceAll('ö', 'o') + .replaceAll('ï', 'i') + .replaceAll('î', 'i') + .replaceAll('ç', 'c') + .replaceAll('œ', 'oe'); + } +} diff --git a/lib/viewmodels/dev_menu_view_model.dart b/lib/viewmodels/dev_menu_view_model.dart index 734bc5a..67af232 100644 --- a/lib/viewmodels/dev_menu_view_model.dart +++ b/lib/viewmodels/dev_menu_view_model.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:kooltab2/database/app_database.dart'; import 'package:kooltab2/services/bar_tab_service.dart'; import 'package:kooltab2/models/payment_method.dart'; +import 'package:kooltab2/services/default_product_seeder.dart'; import 'package:kooltab2/services/pin_lock_service.dart'; import 'package:kooltab2/services/product_service.dart'; import 'package:kooltab2/services/settings_service.dart'; @@ -78,6 +82,67 @@ class DevMenuViewModel extends ChangeNotifier { } } + Future seedDefaultProducts() async { + _isLoading = true; + notifyListeners(); + + try { + await DefaultProductSeeder(database: database).seedIfEmpty(); + final count = await _getProductCount(); + _lastAction = 'Seeded default products ($count total)'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future clearAndReseedProducts() async { + _isLoading = true; + notifyListeners(); + + try { + await database.transaction(() async { + await database.delete(database.tabItems).go(); + await database.delete(database.products).go(); + }); + await DefaultProductSeeder(database: database).seedIfEmpty(); + final count = await _getProductCount(); + _lastAction = 'Cleared and reseeded ($count products)'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future _getProductCount() async { + final countExpr = database.products.id.count(); + final query = database.selectOnly(database.products) + ..addColumns([countExpr]); + final row = await query.getSingle(); + return row.read(countExpr) ?? 0; + } + + Future clearProductImages() async { + _isLoading = true; + notifyListeners(); + + try { + final appDir = await getApplicationSupportDirectory(); + final imagesDir = Directory('${appDir.path}/product_images'); + if (await imagesDir.exists()) { + await imagesDir.delete(recursive: true); + } + _lastAction = 'Product images cleared'; + } catch (e, stack) { + debugPrint('DevMenuViewModel: clearProductImages error: $e'); + Sentry.captureException(e, stackTrace: stack); + _lastAction = 'Failed to clear images: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + Future resetAllStock() async { _isLoading = true; notifyListeners(); diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index 9e22b4c..902208b 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -76,9 +76,16 @@ class _DevMenuViewState extends State { onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(), ), _Tile( - icon: Icons.inventory_2_rounded, - title: 'Reset all stock', - onTap: vm.isLoading ? null : () => vm.resetAllStock(), + icon: Icons.add_box_rounded, + title: 'Seed default products', + subtitle: 'Only seeds if table is empty', + onTap: vm.isLoading ? null : () => vm.seedDefaultProducts(), + ), + _Tile( + icon: Icons.restart_alt_rounded, + title: 'Clear & reseed products', + subtitle: 'Wipes all products, then seeds defaults', + onTap: vm.isLoading ? null : () => vm.clearAndReseedProducts(), ), _Tile( icon: Icons.delete_forever_rounded, @@ -86,9 +93,10 @@ class _DevMenuViewState extends State { onTap: vm.isLoading ? null : () => vm.clearAllProducts(), ), _Tile( - icon: Icons.add_box_rounded, - title: 'Seed demo data', - onTap: vm.isLoading ? null : () => vm.seedDemoData(), + icon: Icons.image_rounded, + title: 'Clear product images', + subtitle: 'Deletes images from product_images folder', + onTap: vm.isLoading ? null : () => vm.clearProductImages(), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 202328a..d2803c0 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: 2.0.0+1 +version: 1.0.3+1 environment: sdk: ^3.12.2 @@ -88,6 +88,7 @@ flutter: assets: - assets/icons/payconic.svg + - assets/products/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images