diff --git a/.gitignore b/.gitignore index 209a70a..b98dd29 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,7 @@ app.*.symbols # AI related .agents/rules/personal-* .agents/skills/personal-* -/.agent-shell/ \ No newline at end of file +/.agent-shell/ + +android/key.properties +android/app/*.jks \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ffb5c79..b5f8582 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,13 @@ +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") + +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -25,11 +35,18 @@ android { versionName = flutter.versionName } + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/deps.txt b/deps.txt deleted file mode 100644 index 3ac036c..0000000 Binary files a/deps.txt and /dev/null differ diff --git a/lib/app/app.dart b/lib/app/app.dart index 4ae0ad7..af88665 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -17,7 +17,9 @@ class _KoolTabAppState extends State { void initState() { super.initState(); - _checkForUpdates(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForUpdates(); + }); } Future _checkForUpdates() async { @@ -27,19 +29,92 @@ class _KoolTabAppState extends State { final update = await updater.checkForUpdate(); if (update == null) { - debugPrint("No update available: "); + debugPrint("No update available"); return; } debugPrint("Update available: ${update.version}"); - // TODO: - // Show update dialog here + if (!mounted) return; + + _showUpdateDialog(update); } catch (e) { debugPrint("Update check failed: $e"); } } + void _showUpdateDialog(UpdateInfo update) { + showDialog( + context: context, + barrierDismissible: !update.mandatory, + builder: (context) { + return AlertDialog( + title: const Text("Update available"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Version ${update.version} is available.", + ), + const SizedBox(height: 12), + Text(update.notes), + ], + ), + actions: [ + if (!update.mandatory) + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("Later"), + ), + + FilledButton( + onPressed: () async { + Navigator.pop(context); + + await _downloadAndInstall(update); + }, + child: const Text("Update"), + ), + ], + ); + }, + ); + } + + Future _downloadAndInstall(UpdateInfo update) async { + final updater = AppUpdateUtil( + serverUrl: "http://localhost:3000", + ); + + try { + final apk = await updater.downloadApk( + update, + onProgress: (progress) { + debugPrint( + "Download ${(progress * 100).toStringAsFixed(0)}%", + ); + }, + ); + + final valid = await updater.verifySha256( + apk, + update.sha256, + ); + + if (!valid) { + throw Exception("Invalid update file"); + } + + await updater.installApk(apk); + + } catch (e) { + debugPrint("Update failed: $e"); + } + } + @override Widget build(BuildContext context) { return MaterialApp.router( diff --git a/lib/main.dart b/lib/main.dart index 232a5b2..c5ec8e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import 'package:kooltab2/services/pin_lock_service.dart'; import 'package:kooltab2/services/settings_service.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'; import 'package:kooltab2/viewmodels/settings_view_model.dart'; import 'package:provider/provider.dart'; @@ -53,14 +54,23 @@ Future main() async { create: (context) => DriftSettingsService(database: context.read()), ), + ChangeNotifierProvider( + create: (context) => InventoryViewModel( + productService: context.read(), + )..load(), + ), ChangeNotifierProvider( create: (context) => ProductListViewModel( productService: context.read(), + inventory: context.read(), ), ), ChangeNotifierProvider( create: (context) => - BarScreenViewModel(barTabService: context.read(), productService: context.read()), + BarScreenViewModel( + barTabService: context.read(), + inventory: context.read(), + ), ), ChangeNotifierProvider( create: (context) => diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index 667f1b1..32eb4cd 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -4,15 +4,15 @@ 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 { final BarTabService barTabService; - final ProductService productService; + final InventoryViewModel inventory; BarScreenViewModel({ required this.barTabService, - required this.productService, + required this.inventory, }); List _tabs = []; @@ -96,7 +96,7 @@ class BarScreenViewModel extends ChangeNotifier { product: product, ); - await productService.decreaseStock(product.id, 1); + await inventory.decreaseStock(product.id, 1); await _reloadTabs(); } @@ -110,9 +110,9 @@ class BarScreenViewModel extends ChangeNotifier { ); if (difference > 0) { - await productService.decreaseStock(item.productId, difference); + await inventory.decreaseStock(item.productId, difference); } else if (difference < 0) { - await productService.increaseStock(item.productId, -difference); + await inventory.increaseStock(item.productId, -difference); } await _reloadTabs(); diff --git a/lib/viewmodels/inventory_view_model.dart b/lib/viewmodels/inventory_view_model.dart new file mode 100644 index 0000000..4834d67 --- /dev/null +++ b/lib/viewmodels/inventory_view_model.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; + +import '../models/product.dart'; +import '../services/product_service.dart'; + +class InventoryViewModel extends ChangeNotifier { + final ProductService productService; + + InventoryViewModel({ + required this.productService, + }); + + List _products = []; + + List get products => _products; + + Future load() async { + _products = await productService.getProducts(); + notifyListeners(); + } + + Future decreaseStock( + String productId, + int amount, + ) async { + await productService.decreaseStock(productId, amount); + + final index = _products.indexWhere( + (product) => product.id == productId, + ); + + if (index != -1) { + _products[index] = _products[index].copyWith( + stockQuantity: + _products[index].stockQuantity - amount, + ); + } + + notifyListeners(); + } + + Future increaseStock( + String productId, + int amount, + ) async { + await productService.increaseStock(productId, amount); + + final index = _products.indexWhere( + (product) => product.id == productId, + ); + + if (index != -1) { + _products[index] = _products[index].copyWith( + stockQuantity: + _products[index].stockQuantity + amount, + ); + } + + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart index e444181..242c8d1 100644 --- a/lib/viewmodels/product_list_view_model.dart +++ b/lib/viewmodels/product_list_view_model.dart @@ -2,12 +2,15 @@ import 'package:flutter/foundation.dart'; import '../models/product.dart'; import '../services/product_service.dart'; +import 'inventory_view_model.dart'; class ProductListViewModel extends ChangeNotifier { final ProductService productService; + final InventoryViewModel inventory; ProductListViewModel({ required this.productService, + required this.inventory, }); static const List _defaultCategories = [ @@ -21,12 +24,11 @@ class ProductListViewModel extends ChangeNotifier { 'Other', ]; - List _products = []; bool _isLoading = false; bool _hasLoaded = false; String? _errorMessage; - List get products => _products; + List get products => inventory.products; bool get isLoading => _isLoading; @@ -48,7 +50,7 @@ class ProductListViewModel extends ChangeNotifier { notifyListeners(); try { - _products = await productService.getProducts(); + await inventory.load(); } catch (_) { _errorMessage = 'Could not load products.'; } finally { @@ -109,7 +111,7 @@ class ProductListViewModel extends ChangeNotifier { List get categories { final categories = { ..._defaultCategories, - ..._products + ...inventory.products .map((p) => p.category.trim()) .where((c) => c.isNotEmpty), }.toList(); diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 5a3c9e2..5ceb2c4 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; import 'package:kooltab2/viewmodels/product_list_view_model.dart'; +import 'package:kooltab2/views/dialogs/close_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'; -import 'dart:io'; import 'package:flutter_slidable/flutter_slidable.dart'; import '../models/bar_tab.dart'; @@ -49,10 +51,10 @@ class BarScreenView extends StatelessWidget { const SizedBox(width: 6), IconButton( tooltip: 'logout', - onPressed: (){ + onPressed: () { Provider.of(context, listen: false).lock(); }, - icon: const Icon(Icons.logout) + icon: const Icon(Icons.logout), ), const SizedBox(width: 8), ], @@ -118,10 +120,10 @@ class BarScreenView extends StatelessWidget { tabs: viewModel.tabs, selectedTab: viewModel.selectedTab, selectedTabId: viewModel.selectedTabId, - onNewTabPressed: () => _showNewTabDialog(context), + onNewTabPressed: () => showNewTabDialog(context), onTabSelected: viewModel.selectTab, onItemQuantityChanged: viewModel.changeItemQuantity, - onCloseTabPressed: () => _confirmCloseTab(context), + onCloseTabPressed: () => confirmCloseTab(context), onTabClosed: viewModel.closeTab, ), ), @@ -131,83 +133,6 @@ class BarScreenView extends StatelessWidget { ), ); } - - Future _showNewTabDialog(BuildContext context) async { - final controller = TextEditingController(); - - final name = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Open new tab'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Customer / group name', - ), - onSubmitted: (value) { - Navigator.of(dialogContext).pop(value); - }, - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(controller.text); - }, - child: const Text('Open tab'), - ), - ], - ); - }, - ); - - if (name == null || name.trim().isEmpty) return; - - if (!context.mounted) return; - - await context.read().createTab(name.trim()); - } - - Future _confirmCloseTab(BuildContext context) async { - final viewModel = context.read(); - final tab = viewModel.selectedTab; - - if (tab == null) return; - - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text('Close ${tab.customerName}ʼs tab?'), - content: Text('Current total: ${tab.formattedTotal}\n\n'), - actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Close tab'), - ), - ], - ); - }, - ); - - if (confirmed != true) return; - - await viewModel.closeSelectedTab(); - } } // --------------------------------------------------------------------------- @@ -329,7 +254,7 @@ class _ProductGrid extends StatelessWidget { itemBuilder: (context, index) { final product = products[index]; - return _ProductTile( + return ProductTile( product: product, enabled: hasSelectedTab, onTap: () => onProductTap(product), @@ -344,98 +269,6 @@ class _ProductGrid extends StatelessWidget { } } -class _ProductTile extends StatelessWidget { - final Product product; - final bool enabled; - final VoidCallback onTap; - - const _ProductTile({ - required this.product, - required this.enabled, - required this.onTap, - }); - - bool get _hasImage { - final imagePath = product.imagePath; - - if (imagePath == null || imagePath.isEmpty) { - return false; - } - - return File(imagePath).existsSync(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: scheme.onSurface.withValues(alpha: 0.08), width: 1), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: Material( - color: theme.cardTheme.color ?? scheme.surface, - child: InkWell( - onTap: enabled ? onTap : null, - child: Opacity( - opacity: enabled ? 1 : 0.4, - child: Stack( - fit: StackFit.expand, - children: [ - _hasImage - ? Image.file( - File(product.imagePath!), - fit: BoxFit.scaleDown, - width: double.infinity, - height: double.infinity, - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.image_not_supported_outlined, - size: 34, - color: scheme.onSurface.withValues(alpha: 0.3), - ), - const SizedBox(height: 6), - Text( - 'No image', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - if (_hasImage) - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.35), - ], - stops: const [0.6, 1.0], - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - // --------------------------------------------------------------------------- // Tab panel // --------------------------------------------------------------------------- @@ -446,8 +279,7 @@ class _TabPanel extends StatefulWidget { final String? selectedTabId; final VoidCallback onNewTabPressed; final ValueChanged onTabSelected; - final Future Function(TabItem item, int quantity) - onItemQuantityChanged; + final Future Function(TabItem item, int quantity) onItemQuantityChanged; final VoidCallback onCloseTabPressed; final ValueChanged onTabClosed; @@ -572,27 +404,27 @@ class _TabPanelState extends State<_TabPanel> { Expanded( child: widget.selectedTab == null ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.receipt_long_outlined, - size: 36, - color: scheme.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 10), - Text( - 'Select or open a tab', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ) + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 36, + color: scheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: 10), + Text( + 'Select or open a tab', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) : _SelectedTabDetails( - tab: widget.selectedTab!, - onItemQuantityChanged: widget.onItemQuantityChanged, - onCloseTabPressed: widget.onCloseTabPressed, - ), + tab: widget.selectedTab!, + onItemQuantityChanged: widget.onItemQuantityChanged, + onCloseTabPressed: widget.onCloseTabPressed, + ), ), ], ), @@ -601,7 +433,6 @@ class _TabPanelState extends State<_TabPanel> { } } - class _OpenTabsList extends StatelessWidget { final List tabs; final String? selectedTabId; @@ -721,9 +552,6 @@ class _OpenTabsList extends StatelessWidget { } } - - - class _SelectedTabDetails extends StatelessWidget { final BarTab tab; final Future Function(TabItem item, int quantity) onItemQuantityChanged; @@ -752,50 +580,54 @@ class _SelectedTabDetails extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), ), - Divider() + Divider(), ], ), const SizedBox(height: 16), Expanded( child: tab.items.isEmpty ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.local_bar_outlined, - size: 32, - color: scheme.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 8), - Text( - 'Tap products to add them', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ) + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_bar_outlined, + size: 32, + color: scheme.onSurface.withValues(alpha: 0.25), + ), + const SizedBox(height: 8), + Text( + 'Tap products to add them', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) : ListView.separated( - itemCount: tab.items.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = tab.items[index]; + itemCount: tab.items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = tab.items[index]; - return _TabItemRow( - item: item, - onQuantityChanged: onItemQuantityChanged, - ); - }, - ), + return _TabItemRow( + item: item, + onQuantityChanged: onItemQuantityChanged, + ); + }, + ), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.12), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), border: Border.all( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), ), ), child: Row( @@ -807,8 +639,9 @@ class _SelectedTabDetails extends StatelessWidget { Text('Total', style: Theme.of(context).textTheme.bodySmall), Text( tab.formattedTotal, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontSize: 24), + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontSize: 24), ), ], ), @@ -867,8 +700,7 @@ class _TabItemRow extends StatelessWidget { children: [ IconButton( visualDensity: VisualDensity.compact, - onPressed: () => - onQuantityChanged(item, item.quantity - 1), + onPressed: () => onQuantityChanged(item, item.quantity - 1), icon: const Icon(Icons.remove_rounded, size: 18), ), SizedBox( @@ -881,8 +713,7 @@ class _TabItemRow extends StatelessWidget { ), IconButton( visualDensity: VisualDensity.compact, - onPressed: () => - onQuantityChanged(item, item.quantity + 1), + onPressed: () => onQuantityChanged(item, item.quantity + 1), icon: const Icon(Icons.add_rounded, size: 18), ), ], @@ -900,4 +731,4 @@ class _TabItemRow extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart new file mode 100644 index 0000000..b446092 --- /dev/null +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -0,0 +1,47 @@ +import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltab2/views/widgets/slide_confirm.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter/material.dart'; + +Future confirmCloseTab(BuildContext context) async { + final viewModel = context.read(); + final tab = viewModel.selectedTab; + + if (tab == null) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Close ${tab.customerName}ʼs tab?'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Current total: ${tab.formattedTotal}'), + const SizedBox(height: 20), + + SlideConfirm( + onConfirmed: () { + Navigator.of(dialogContext).pop(true); + }, + ), + ], + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + await viewModel.closeSelectedTab(); +} \ No newline at end of file diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart new file mode 100644 index 0000000..692fe71 --- /dev/null +++ b/lib/views/dialogs/new_tab_dialog.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:kooltab2/viewmodels/bar_screen_view_model.dart'; +import 'package:provider/provider.dart'; + + +Future showNewTabDialog(BuildContext context) async { + final controller = TextEditingController(); + + final name = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Open new tab'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Customer / group name', + ), + onSubmitted: (value) { + Navigator.of(dialogContext).pop(value); + }, + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(controller.text); + }, + child: const Text('Open tab'), + ), + ], + ); + }, + ); + + if (name == null || name.trim().isEmpty) return; + + if (!context.mounted) return; + + await context.read().createTab(name.trim()); +} \ No newline at end of file diff --git a/lib/views/widgets/product_tile.dart b/lib/views/widgets/product_tile.dart new file mode 100644 index 0000000..e70b2dd --- /dev/null +++ b/lib/views/widgets/product_tile.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:kooltab2/models/product.dart'; + + +class ProductTile extends StatelessWidget { + final Product product; + final bool enabled; + final VoidCallback onTap; + + const ProductTile({ + required this.product, + required this.enabled, + required this.onTap, + }); + + bool get _hasImage { + final imagePath = product.imagePath; + + if (imagePath == null || imagePath.isEmpty) { + return false; + } + + return File(imagePath).existsSync(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final outOfStock = product.stockQuantity <= 0; + final lowStock = + product.stockQuantity > 0 && + product.stockQuantity <= product.lowStockThreshold; // adjust name + + final borderColor = outOfStock + ? scheme.error.withValues(alpha: 0.7) + : lowStock + ? Colors.amber.withValues(alpha: 0.9) + : scheme.onSurface.withValues(alpha: 0.08); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: borderColor, + width: outOfStock || lowStock ? 2 : 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Material( + color: theme.cardTheme.color ?? scheme.surface, + child: InkWell( + onTap: enabled && !outOfStock ? onTap : null, + child: Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4), + child: _hasImage + ? Image.file( + File(product.imagePath!), + fit: BoxFit.scaleDown, + ) + : Center( + child: Icon( + Icons.image_not_supported_outlined, + size: 34, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ), + ), + + if (_hasImage) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.35), + ], + stops: const [0.6, 1.0], + ), + ), + ), + ), + + // Stock badge + Positioned( + top: 10, + right: 10, + child: _StockBadge( + product: product, + ), + ), + + // Out of stock overlay + if (outOfStock) + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: scheme.error.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'OUT OF STOCK', + style: TextStyle( + color: scheme.onError, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _StockBadge extends StatelessWidget { + final Product product; + + const _StockBadge({ + required this.product, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (product.stockQuantity <= 0) { + return const SizedBox.shrink(); + } + + final low = + product.stockQuantity <= product.lowStockThreshold; // adjust name + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: low + ? Colors.amber.withValues(alpha: 0.9) + : scheme.primary.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${product.stockQuantity}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: low ? Colors.black : scheme.onPrimary, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/views/widgets/slide_confirm.dart b/lib/views/widgets/slide_confirm.dart new file mode 100644 index 0000000..7d7729b --- /dev/null +++ b/lib/views/widgets/slide_confirm.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + + +class SlideConfirm extends StatefulWidget { + final VoidCallback onConfirmed; + + const SlideConfirm({required this.onConfirmed}); + + @override + State createState() => _SlideConfirmState(); +} + +class _SlideConfirmState extends State { + double _drag = 0; + bool _confirmed = false; + + static const double size = 52; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return SizedBox( + width: double.infinity, + height: 58, + child: LayoutBuilder( + builder: (context, constraints) { + final maxDrag = constraints.maxWidth - size; + + return Container( + height: 58, + decoration: BoxDecoration( + color: scheme.error.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(30), + ), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Center( + child: Text( + _confirmed ? 'Closing tab...' : 'Slide to confirm closing', + style: TextStyle( + color: scheme.error, + fontWeight: FontWeight.w700, + ), + ), + ), + + Positioned( + left: _drag, + child: GestureDetector( + onHorizontalDragUpdate: (details) { + if (_confirmed) return; + + setState(() { + _drag += details.delta.dx; + _drag = _drag.clamp(0, maxDrag); + }); + }, + onHorizontalDragEnd: (_) { + if (_drag >= maxDrag * 0.85) { + setState(() { + _confirmed = true; + _drag = maxDrag; + }); + + Future.delayed( + const Duration(milliseconds: 250), + widget.onConfirmed, + ); + } else { + setState(() { + _drag = 0; + }); + } + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: scheme.error, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_forward_rounded, + color: scheme.onError, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ef73abe..18f1900 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.0+1 +version: 1.0.1+1 environment: sdk: ^3.12.2