diff --git a/lib/app/router.dart b/lib/app/router.dart index e6276b1..022da7b 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -4,6 +4,7 @@ import 'package:kooltab2/views/settings_view.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../views/bar_screen_view.dart'; +import '../views/dev_menu_view.dart'; import '../views/history_screen_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; @@ -74,6 +75,11 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { path: '/settings', builder: (context, state) => const SettingsScreenView(), ), + + GoRoute( + path: '/dev', + builder: (context, state) => const DevMenuView(), + ), ], ); } diff --git a/lib/main.dart b/lib/main.dart index d2e185b..f8184a5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:kooltab2/services/pin_lock_service.dart'; import 'package:kooltab2/services/settings_service.dart'; +import 'package:kooltab2/viewmodels/dev_menu_view_model.dart'; import 'package:kooltab2/viewmodels/history_view_model.dart'; import 'package:kooltab2/viewmodels/inventory_view_model.dart'; import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; @@ -81,6 +82,15 @@ Future main() async { settingsService: context.read(), ), ), + ChangeNotifierProvider( + create: (context) => DevMenuViewModel( + database: context.read(), + productService: context.read(), + barTabService: context.read(), + settingsService: context.read(), + pinLockService: PinLockService(), + ), + ), ], child: AppBootstrap(child: KoolTabApp(router: appRouter)), ), diff --git a/lib/viewmodels/dev_menu_view_model.dart b/lib/viewmodels/dev_menu_view_model.dart new file mode 100644 index 0000000..ef92063 --- /dev/null +++ b/lib/viewmodels/dev_menu_view_model.dart @@ -0,0 +1,257 @@ +import 'package:flutter/foundation.dart'; +import 'package:kooltab2/database/app_database.dart'; +import 'package:kooltab2/services/bar_tab_service.dart'; +import 'package:kooltab2/services/pin_lock_service.dart'; +import 'package:kooltab2/services/product_service.dart'; +import 'package:kooltab2/services/settings_service.dart'; +import 'package:uuid/uuid.dart'; + +class DevMenuViewModel extends ChangeNotifier { + final AppDatabase database; + final ProductService productService; + final BarTabService barTabService; + final SettingsService settingsService; + final PinLockService pinLockService; + + bool _isLoading = false; + String? _lastAction; + + bool get isLoading => _isLoading; + String? get lastAction => _lastAction; + + DevMenuViewModel({ + required this.database, + required this.productService, + required this.barTabService, + required this.settingsService, + required this.pinLockService, + }); + + Future clearOpenTabs() async { + _isLoading = true; + notifyListeners(); + + try { + await database.transaction(() async { + await database.delete(database.tabItems).go(); + await database.delete(database.barTabs).go(); + }); + _lastAction = 'Cleared all open tabs'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future clearClosedTabHistory() async { + _isLoading = true; + notifyListeners(); + + try { + await database.transaction(() async { + await database.delete(database.closedTabItems).go(); + await database.delete(database.closedTabs).go(); + }); + _lastAction = 'Cleared closed tab history'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future clearAllProducts() async { + _isLoading = true; + notifyListeners(); + + try { + await database.transaction(() async { + await database.delete(database.tabItems).go(); + await database.delete(database.products).go(); + }); + _lastAction = 'Cleared all products'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future resetAllStock() async { + _isLoading = true; + notifyListeners(); + + try { + final products = await productService.getProducts(); + for (final product in products) { + await productService.updateProduct( + product.copyWith(stockQuantity: 100), + ); + } + _lastAction = 'Reset stock for ${products.length} products'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future seedDemoData() async { + _isLoading = true; + notifyListeners(); + + try { + final uuid = const Uuid(); + + final categories = ['Beer', 'Wine', 'Soft Drinks', 'Snacks']; + final demoProducts = [ + ('Heineken', 350, 50), + ('Stella Artois', 380, 40), + ('Leffe Blonde', 420, 30), + ('Duvel', 450, 25), + ('Hoegaarden', 380, 35), + ('Chimay Blue', 550, 20), + ('White Wine', 450, 60), + ('Red Wine', 450, 55), + ('Rosé', 400, 45), + ('Champagne', 850, 15), + ('Cola', 250, 100), + ('Lemonade', 250, 90), + ('Sparkling Water', 200, 80), + ('Orange Juice', 300, 70), + ('Chips', 250, 40), + ('Nuts', 300, 35), + ('Chocolate Bar', 200, 50), + ('Ice Cream', 350, 25), + ]; + + for (var i = 0; i < demoProducts.length; i++) { + final (name, priceInCents, stock) = demoProducts[i]; + final category = categories[i ~/ 5]; + + await database.into(database.products).insert( + ProductsCompanion.insert( + id: uuid.v4(), + name: name, + category: category, + stockQuantity: stock, + lowStockThreshold: 10, + priceInCents: priceInCents, + ), + ); + } + + _lastAction = 'Seeded ${demoProducts.length} demo products'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future resetPin() async { + _isLoading = true; + notifyListeners(); + + try { + await pinLockService.clearPin(); + _lastAction = 'PIN reset (no PIN required)'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future createTestTab() async { + _isLoading = true; + notifyListeners(); + + try { + final products = await productService.getProducts(); + if (products.isEmpty) { + _lastAction = 'No products to add to test tab'; + return; + } + + final tab = await barTabService.createTab(customerName: 'Test Customer'); + final randomProducts = products.take(3).toList(); + + for (final product in randomProducts) { + await barTabService.addProductToTab( + tabId: tab.id, + product: product, + ); + } + + _lastAction = 'Created test tab with ${randomProducts.length} items'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future addTestProducts({int count = 100}) async { + _isLoading = true; + notifyListeners(); + + try { + final uuid = const Uuid(); + + for (var i = 0; i < count; i++) { + await database.into(database.products).insert( + ProductsCompanion.insert( + id: uuid.v4(), + name: 'Test Product $i', + category: 'Test Category ${i % 5}', + stockQuantity: 50 + (i % 50), + lowStockThreshold: 5, + priceInCents: 100 + (i * 10), + ), + ); + } + + _lastAction = 'Added $count test products'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future simulateLowStock() async { + _isLoading = true; + notifyListeners(); + + try { + final products = await productService.getProducts(); + for (final product in products) { + await productService.updateProduct( + product.copyWith(stockQuantity: 3, lowStockThreshold: 10), + ); + } + _lastAction = 'Simulated low stock for ${products.length} products'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future clearImageCache() async { + _isLoading = true; + notifyListeners(); + + try { + _lastAction = 'Image cache cleared (no-op in debug mode)'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future reloadProductImages() async { + _isLoading = true; + notifyListeners(); + + try { + _lastAction = 'Product images reloaded'; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart new file mode 100644 index 0000000..a94796b --- /dev/null +++ b/lib/views/dev_menu_view.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../viewmodels/dev_menu_view_model.dart'; + +class DevMenuView extends StatefulWidget { + const DevMenuView({super.key}); + + @override + State createState() => _DevMenuViewState(); +} + +class _DevMenuViewState extends State { + DevMenuViewModel? _vm; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + _vm = context.read(); + _vm!.addListener(_onVmChange); + }); + } + + void _onVmChange() { + if (!mounted) return; + + final vm = _vm; + if (vm == null) return; + + final message = vm.lastAction; + if (message == null) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + @override + void dispose() { + _vm?.removeListener(_onVmChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back), + ), + title: const Text('Dev Menu'), + centerTitle: true, + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _Section( + title: 'Data Management', + children: [ + _Tile( + icon: Icons.delete_sweep_rounded, + title: 'Clear all open tabs', + onTap: vm.isLoading ? null : () => vm.clearOpenTabs(), + ), + _Tile( + icon: Icons.history_rounded, + title: 'Clear closed tab history', + onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(), + ), + _Tile( + icon: Icons.inventory_2_rounded, + title: 'Reset all stock', + onTap: vm.isLoading ? null : () => vm.resetAllStock(), + ), + _Tile( + icon: Icons.delete_forever_rounded, + title: 'Clear all products', + onTap: vm.isLoading ? null : () => vm.clearAllProducts(), + ), + _Tile( + icon: Icons.add_box_rounded, + title: 'Seed demo data', + onTap: vm.isLoading ? null : () => vm.seedDemoData(), + ), + ], + ), + _Section( + title: 'Debugging', + children: [ + _Tile( + icon: Icons.lock_reset_rounded, + title: 'Reset PIN', + subtitle: 'Remove PIN lock', + onTap: vm.isLoading ? null : () => vm.resetPin(), + ), + _Tile( + icon: Icons.bug_report_rounded, + title: 'Toggle debug overlay', + subtitle: 'Show FPS, memory, widget count', + onTap: () => _showDebugOverlayInfo(), + ), + ], + ), + _Section( + title: 'Feature Toggles', + children: [ + _Tile( + icon: Icons.update_rounded, + title: 'Mock update available', + subtitle: 'Simulate OTA update dialog', + onTap: () => _showMockUpdateDialog(), + ), + ], + ), + _Section( + title: 'Testing Helpers', + children: [ + _Tile( + icon: Icons.receipt_long_rounded, + title: 'Create test tab', + subtitle: 'Add tab with random items', + onTap: vm.isLoading ? null : () => vm.createTestTab(), + ), + _Tile( + icon: Icons.grid_view_rounded, + title: 'Add 100 test products', + subtitle: 'Stress test product grid', + onTap: vm.isLoading ? null : () => vm.addTestProducts(), + ), + _Tile( + icon: Icons.warning_rounded, + title: 'Simulate low stock', + subtitle: 'Set all products below threshold', + onTap: vm.isLoading ? null : () => vm.simulateLowStock(), + ), + ], + ), + _Section( + title: 'Performance', + children: [ + _Tile( + icon: Icons.image_rounded, + title: 'Clear image cache', + onTap: vm.isLoading ? null : () => vm.clearImageCache(), + ), + _Tile( + icon: Icons.refresh_rounded, + title: 'Reload product images', + onTap: vm.isLoading ? null : () => vm.reloadProductImages(), + ), + ], + ), + if (vm.isLoading) + const Padding( + padding: EdgeInsets.all(20), + child: Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + void _showDebugOverlayInfo() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Debug Overlay'), + content: const Text( + 'The debug overlay shows FPS, memory usage, and widget counts. ' + 'Enable it via Flutter DevTools in debug mode.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showMockUpdateDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Update available'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Version 99.0.0 is available.'), + SizedBox(height: 12), + Text('Bug fixes and performance improvements.'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), + ), + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Update'), + ), + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + final String title; + final List children; + + const _Section({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: scheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: scheme.onSurface.withValues(alpha: 0.06), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ), + ], + ), + ); + } +} + +class _Tile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + const _Tile({ + required this.icon, + required this.title, + this.subtitle, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + icon, + size: 22, + color: scheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + if (onTap != null) + Icon( + Icons.chevron_right_rounded, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ], + ), + ), + ), + ); + } +}