feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+18 -17
View File
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/remote_command.dart';
import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
@@ -41,7 +42,7 @@ class AdminScreenView extends StatelessWidget {
);
if (confirmed == true && context.mounted) {
context.read<RemoteSyncViewModel>().unlink();
await context.read<RemoteSyncViewModel>().unlink();
}
}
@@ -135,8 +136,13 @@ class AdminScreenView extends StatelessWidget {
subtitle: remoteSync.isSyncing
? l10n.syncing
: remoteSync.lastSyncAt != null
? l10n.lastSync(remoteSync.lastSyncAt!.toLocal().toString().substring(0, 19))
: l10n.notSyncedYet,
? l10n.lastSync(
remoteSync.lastSyncAt!
.toLocal()
.toString()
.substring(0, 19),
)
: l10n.notSyncedYet,
trailing: remoteSync.isSyncing
? const SizedBox(
width: 20,
@@ -154,20 +160,18 @@ class AdminScreenView extends StatelessWidget {
children: remoteSync.activity.map((entry) {
return ListTile(
dense: true,
leading: Icon(
entry.result == 'completed'
? Icons.check_circle_outline
: entry.result == 'conflict'
? Icons.warning_amber_outlined
: Icons.error_outline,
size: 20,
),
leading: Icon(switch (entry.result) {
CommandResult.completed => Icons.check_circle_outline,
CommandResult.conflict => Icons.warning_amber_outlined,
CommandResult.failed ||
CommandResult.expired => Icons.error_outline,
}, size: 20),
title: Text(
'${entry.commandType} (${entry.commandId.substring(0, 8)}...)',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${entry.processedAt.toLocal()} - ${entry.result}',
'${entry.processedAt.toLocal()} - ${entry.result.name}',
style: const TextStyle(fontSize: 11),
),
);
@@ -277,10 +281,7 @@ class _AdminTile extends StatelessWidget {
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
if (onTap != null)
Icon(
Icons.chevron_right_rounded,
@@ -292,4 +293,4 @@ class _AdminTile extends StatelessWidget {
),
);
}
}
}
+263
View File
@@ -0,0 +1,263 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/app_localizations_helpers.dart';
import '../../models/product.dart';
import '../../models/remote_command.dart';
import '../../services/product_service.dart';
import '../../utils/app_updater.dart';
import '../../viewmodels/admin_pin_view_model.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import '../../viewmodels/pin_lock_view_model.dart';
import '../../viewmodels/product_list_view_model.dart';
import '../../viewmodels/remote_sync_view_model.dart';
import '../../viewmodels/settings_view_model.dart';
import '../dialogs/close_tab_dialog.dart';
import '../dialogs/edit_tab_dialog.dart';
import '../dialogs/new_tab_dialog.dart';
import 'product_grid.dart';
import 'tab_panel.dart';
class BarScreenView extends StatefulWidget {
const BarScreenView({super.key});
@override
State<BarScreenView> createState() => _BarScreenViewState();
}
class _BarScreenViewState extends State<BarScreenView> {
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
@override
void initState() {
super.initState();
_remoteCloseSubscription = context
.read<RemoteSyncViewModel>()
.tabsClosedRemotely
.listen(_showRemoteClose);
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context);
});
}
@override
void dispose() {
_remoteCloseSubscription.cancel();
super.dispose();
}
void _showRemoteClose(RemoteSyncEvent event) {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.tabClosedRemotely(
event.tabCustomerName ?? '',
event.requestedBy ?? '',
),
),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _addProduct(Product product) async {
final l10n = AppLocalizations.of(context);
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.selectedTab == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.openOrSelectTab)));
return;
}
try {
await viewModel.addProductToSelectedTab(product);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is InsufficientStockException
? l10n.productOutOfStock
: l10n.couldNotAddProductToTab,
),
),
);
}
}
void _logout() {
context.read<PinLockViewModel>().lock();
context.read<AdminPinViewModel>().lock();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final viewModel = context.watch<BarScreenViewModel>();
final productsViewModel = context.watch<ProductListViewModel>();
final settings = context.watch<SettingsViewModel>().settings;
final stockByProductId = {
for (final product in productsViewModel.products)
product.id: product.stockQuantity,
};
return Scaffold(
appBar: AppBar(
title: Text(l10n.barTabs),
actions: [
IconButton(
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
if (kDebugMode) ...[
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.manageProducts,
icon: Icons.inventory_2_outlined,
location: '/products',
),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => context.read<RemoteSyncViewModel>().syncNow(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.settings,
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings),
),
if (context.watch<PinLockViewModel>().isPinSet) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.logout,
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
),
resizeToAvoidBottomInset: false,
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
if (viewModel.loadError != null) {
return _LoadError(message: l10n.errorText(viewModel.loadError));
}
return Row(
children: [
Expanded(
flex: 2,
child: ProductGrid(
products: productsViewModel.products,
rows: settings.barGridRows,
hasSelectedTab: viewModel.selectedTab != null,
onProductTap: _addProduct,
),
),
Container(width: 1, color: Theme.of(context).dividerColor),
Expanded(
child: TabPanel(
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
stockByProductId: stockByProductId,
onNewTabPressed: () => showNewTabDialog(context),
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
),
],
);
},
),
);
}
}
class _DebugShortcut extends StatelessWidget {
final String tooltip;
final IconData icon;
final String location;
const _DebugShortcut({
required this.tooltip,
required this.icon,
required this.location,
});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip,
style: IconButton.styleFrom(
backgroundColor: Colors.yellow,
foregroundColor: Colors.black,
),
onPressed: () => context.push(location),
icon: Icon(icon),
);
}
}
class _LoadError extends StatelessWidget {
final String message;
const _LoadError({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: 40,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../l10n/app_localizations.dart';
import '../../models/product.dart';
import '../widgets/product_tile.dart';
class ProductGrid extends StatelessWidget {
final List<Product> products;
final int rows;
final bool hasSelectedTab;
final ValueChanged<Product> onProductTap;
const ProductGrid({
super.key,
required this.products,
required this.rows,
required this.hasSelectedTab,
required this.onProductTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
if (products.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.onSurface.withValues(alpha: 0.05),
),
child: Icon(
Icons.inventory_2_outlined,
size: 40,
color: scheme.onSurface.withValues(alpha: 0.3),
),
),
const SizedBox(height: 16),
Text(
l10n.noProductsYet,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.addFirstProduct,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
label: Text(l10n.admin),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 4),
child: Row(
children: [
Text(
l10n.products,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 10),
if (!hasSelectedTab)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline_rounded,
size: 14,
color: scheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.selectTabToAddItems,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
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(
crossAxisCount: columns,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
final product = products[index];
return ProductTile(
product: product,
enabled: hasSelectedTab,
onTap: () => onProductTap(product),
);
},
);
},
),
),
],
);
}
}
+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../services/product_service.dart';
import '../../utils/money.dart';
class SelectedTabDetails extends StatelessWidget {
final BarTab tab;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
const SelectedTabDetails({
super.key,
required this.tab,
required this.stockByProductId,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
),
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(
l10n.tapProductsToAdd,
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];
return TabItemRow(
item: item,
stockByProductId: stockByProductId,
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),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.total,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
tab.totalInCents.asEuro,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontSize: 24),
),
],
),
),
FilledButton(
onPressed: tab.items.isEmpty ? null : onCloseTabPressed,
child: Text(l10n.closeTab),
),
],
),
),
],
);
}
}
class TabItemRow extends StatelessWidget {
final TabItem item;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
const TabItemRow({
super.key,
required this.item,
required this.stockByProductId,
required this.onQuantityChanged,
});
Future<void> _changeQuantity(BuildContext context, int delta) async {
final l10n = AppLocalizations.of(context);
try {
await onQuantityChanged(item, delta);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is InsufficientStockException
? l10n.productOutOfStock
: l10n.couldNotUpdateQuantity,
),
),
);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final availableStock = stockByProductId[item.productId];
final canIncrease = availableStock == null || availableStock > 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
'${item.quantity} × ${item.unitPriceInCents.asEuro}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Container(
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => _changeQuantity(context, -1),
icon: const Icon(Icons.remove_rounded, size: 18),
),
SizedBox(
width: 22,
child: Text(
'${item.quantity}',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: canIncrease
? () => _changeQuantity(context, 1)
: null,
icon: const Icon(Icons.add_rounded, size: 18),
),
],
),
),
SizedBox(
width: 72,
child: Text(
item.lineTotalInCents.asEuro,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../utils/money.dart';
import 'selected_tab_details.dart';
class TabPanel extends StatefulWidget {
final List<BarTab> tabs;
final BarTab? selectedTab;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final VoidCallback onNewTabPressed;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
const TabPanel({
super.key,
required this.tabs,
required this.selectedTab,
required this.selectedTabId,
required this.stockByProductId,
required this.onNewTabPressed,
required this.onTabSelected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
State<TabPanel> createState() => _TabPanelState();
}
class _TabPanelState extends State<TabPanel> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
final filteredTabs = widget.tabs.where((tab) {
return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
}).toList();
return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: Icon(Icons.search_rounded, size: 20),
suffixIcon: _searchQuery.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: const Icon(Icons.clear_rounded),
tooltip: l10n.clearSearch,
),
isDense: true,
),
),
),
const SizedBox(width: 10),
IconButton.filled(
onPressed: widget.onNewTabPressed,
icon: const Icon(Icons.add_rounded),
tooltip: l10n.openNewTab,
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 190,
child: OpenTabsList(
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
onTabSelected: widget.onTabSelected,
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
),
),
const Divider(height: 28),
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(
l10n.selectOrOpenTab,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId: widget.stockByProductId,
onItemQuantityChanged: widget.onItemQuantityChanged,
onCloseTabPressed: widget.onCloseTabPressed,
),
),
],
),
),
);
}
}
class OpenTabsList extends StatelessWidget {
final List<BarTab> tabs;
final String? selectedTabId;
final ValueChanged<String> onTabSelected;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
const OpenTabsList({
super.key,
required this.tabs,
required this.selectedTabId,
required this.onTabSelected,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (tabs.isEmpty) {
return Center(
child: Text(
l10n.noOpenTabs,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return ListView.separated(
itemCount: tabs.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final tab = tabs[index];
final selected = tab.id == selectedTabId;
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.6,
children: [
SlidableAction(
onPressed: (_) => onTabEdited(tab),
icon: Icons.edit_outlined,
label: l10n.edit,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
SlidableAction(
onPressed: (_) => onTabDeleted(tab),
icon: Icons.delete_outline_rounded,
label: l10n.delete,
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? primary.withValues(alpha: 0.6)
: scheme.onSurface.withValues(alpha: 0.06),
width: selected ? 1.4 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onTabSelected(tab.id),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
l10n.tabItemSummary(
tab.itemCount,
tab.totalInCents.asEuro,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
),
),
),
);
},
);
}
}
-884
View File
@@ -1,884 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:kooltab2/viewmodels/admin_pin_view_model.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';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../models/bar_tab.dart';
import '../models/product.dart';
import '../models/tab_item.dart';
import '../models/remote_command.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';
class BarScreenView extends StatefulWidget {
const BarScreenView({super.key});
@override
State<BarScreenView> createState() => _BarScreenViewState();
}
class _BarScreenViewState extends State<BarScreenView> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context);
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkRemoteEvent();
}
@override
void didUpdateWidget(covariant BarScreenView oldWidget) {
super.didUpdateWidget(oldWidget);
_checkRemoteEvent();
}
void _checkRemoteEvent() {
final viewModel = context.read<BarScreenViewModel>();
final event = viewModel.lastRemoteEvent;
if (event == null) return;
viewModel.clearRemoteEvent();
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
final message = l10n.tabClosedRemotely(
event.tabCustomerName ?? '',
event.requestedBy ?? '',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final viewModel = context.watch<BarScreenViewModel>();
final productsViewModel = context.watch<ProductListViewModel>();
final settings = context.watch<SettingsViewModel>().settings;
final stockByProductId = {
for (final product in productsViewModel.products)
product.id: product.stockQuantity,
};
return Scaffold(
appBar: AppBar(
title: Text(l10n.barTabs),
actions: [
IconButton(
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
if (kDebugMode) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.manageProducts,
style: IconButton.styleFrom(
backgroundColor: Colors.yellow,
foregroundColor: Colors.black,
),
onPressed: () => context.push('/products'),
icon: const Icon(Icons.inventory_2_outlined),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.tabHistory,
style: IconButton.styleFrom(
backgroundColor: Colors.yellow,
foregroundColor: Colors.black,
),
onPressed: () => context.push('/history'),
icon: const Icon(Icons.history_rounded),
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => viewModel.loadAndSync(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.settings,
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings),
),
if (context.watch<PinLockViewModel>().isPinSet) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.logout,
onPressed: () {
Provider.of<PinLockViewModel>(context, listen: false).lock();
Provider.of<AdminPinViewModel>(context, listen: false).lock();
},
icon: const Icon(Icons.logout),
),
],
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
),
resizeToAvoidBottomInset: false,
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
if (viewModel.errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: 40,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 12),
Text(
l10n.localizedError(viewModel.errorMessage),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
return Row(
children: [
Expanded(
flex: 2,
child: _ProductGrid(
products: productsViewModel.products,
rows: settings.barGridRows,
hasSelectedTab: viewModel.selectedTab != null,
onProductTap: (product) async {
if (viewModel.selectedTab == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.openOrSelectTab)),
);
return;
}
try {
await viewModel.addProductToSelectedTab(product);
} catch (e, stack) {
if (e is! InsufficientStockException) {
Sentry.captureException(e, stackTrace: stack);
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is InsufficientStockException
? l10n.productOutOfStock
: l10n.couldNotAddProductToTab,
),
),
);
}
},
),
),
Container(width: 1, color: Theme.of(context).dividerColor),
Expanded(
flex: 1,
child: _TabPanel(
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
stockByProductId: stockByProductId,
onNewTabPressed: () => showNewTabDialog(context),
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
),
],
);
},
),
);
}
}
class _ProductGrid extends StatelessWidget {
final List<Product> products;
final int rows;
final bool hasSelectedTab;
final ValueChanged<Product> onProductTap;
const _ProductGrid({
required this.products,
required this.rows,
required this.hasSelectedTab,
required this.onProductTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
if (products.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.onSurface.withValues(alpha: 0.05),
),
child: Icon(
Icons.inventory_2_outlined,
size: 40,
color: scheme.onSurface.withValues(alpha: 0.3),
),
),
const SizedBox(height: 16),
Text(
l10n.noProductsYet,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.addFirstProduct,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
label: Text(l10n.admin),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 4),
child: Row(
children: [
Text(
l10n.products,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 10),
if (!hasSelectedTab)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline_rounded,
size: 14,
color: scheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.selectTabToAddItems,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
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(
crossAxisCount: columns,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
final product = products[index];
return ProductTile(
product: product,
enabled: hasSelectedTab,
onTap: () => onProductTap(product),
);
},
);
},
),
),
],
);
}
}
class _TabPanel extends StatefulWidget {
final List<BarTab> tabs;
final BarTab? selectedTab;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final VoidCallback onNewTabPressed;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
const _TabPanel({
required this.tabs,
required this.selectedTab,
required this.selectedTabId,
required this.stockByProductId,
required this.onNewTabPressed,
required this.onTabSelected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
State<_TabPanel> createState() => _TabPanelState();
}
class _TabPanelState extends State<_TabPanel> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
final filteredTabs = widget.tabs.where((tab) {
return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
}).toList();
return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: l10n.searchByName,
prefixIcon: Icon(Icons.search_rounded, size: 20),
suffixIcon: _searchQuery.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: const Icon(Icons.clear_rounded),
tooltip: l10n.clearSearch,
),
isDense: true,
),
),
),
const SizedBox(width: 10),
IconButton.filled(
onPressed: widget.onNewTabPressed,
icon: const Icon(Icons.add_rounded),
tooltip: l10n.openNewTab,
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 190,
child: _OpenTabsList(
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
onTabSelected: widget.onTabSelected,
onTabEdited: widget.onTabEdited,
onTabDeleted: widget.onTabDeleted,
),
),
const Divider(height: 28),
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(
l10n.selectOrOpenTab,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: _SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId: widget.stockByProductId,
onItemQuantityChanged: widget.onItemQuantityChanged,
onCloseTabPressed: widget.onCloseTabPressed,
),
),
],
),
),
);
}
}
class _OpenTabsList extends StatelessWidget {
final List<BarTab> tabs;
final String? selectedTabId;
final ValueChanged<String> onTabSelected;
final Future<void> Function(BarTab) onTabEdited;
final Future<void> Function(BarTab) onTabDeleted;
const _OpenTabsList({
required this.tabs,
required this.selectedTabId,
required this.onTabSelected,
required this.onTabEdited,
required this.onTabDeleted,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (tabs.isEmpty) {
return Center(
child: Text(
l10n.noOpenTabs,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return ListView.separated(
itemCount: tabs.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final tab = tabs[index];
final selected = tab.id == selectedTabId;
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.6,
children: [
SlidableAction(
onPressed: (_) => onTabEdited(tab),
icon: Icons.edit_outlined,
label: l10n.edit,
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
SlidableAction(
onPressed: (_) => onTabDeleted(tab),
icon: Icons.delete_outline_rounded,
label: l10n.delete,
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? primary.withValues(alpha: 0.14)
: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? primary.withValues(alpha: 0.6)
: scheme.onSurface.withValues(alpha: 0.06),
width: selected ? 1.4 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onTabSelected(tab.id),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
l10n.tabItemSummary(
tab.itemCount,
tab.formattedTotal,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
),
),
),
);
},
);
}
}
class _SelectedTabDetails extends StatelessWidget {
final BarTab tab;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
const _SelectedTabDetails({
required this.tab,
required this.stockByProductId,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
),
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(
l10n.tapProductsToAdd,
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];
return _TabItemRow(
item: item,
stockByProductId: stockByProductId,
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),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.total,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
tab.formattedTotal,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontSize: 24),
),
],
),
),
FilledButton(
onPressed: tab.items.isEmpty ? null : onCloseTabPressed,
child: Text(l10n.closeTab),
),
],
),
),
],
);
}
}
class _TabItemRow extends StatelessWidget {
final TabItem item;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) 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),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
'${item.quantity} × ${item.formattedUnitPrice}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Container(
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () async {
try {
await onQuantityChanged(item, -1);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.couldNotUpdateQuantity)),
);
}
}
},
icon: const Icon(Icons.remove_rounded, size: 18),
),
SizedBox(
width: 22,
child: Text(
'${item.quantity}',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: canIncrease
? () async {
try {
await onQuantityChanged(item, 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),
),
],
),
),
SizedBox(
width: 72,
child: Text(
item.formattedLineTotal,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
+6 -3
View File
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../utils/app_update_util.dart';
import '../utils/navigation.dart';
import '../viewmodels/dev_menu_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
class DevMenuView extends StatefulWidget {
const DevMenuView({super.key});
@@ -68,7 +68,10 @@ class _DevMenuViewState extends State<DevMenuView> {
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),),
const Text(
'Hallo Matteo',
style: TextStyle(color: Colors.pinkAccent),
),
_Section(
title: l10n.dataManagement,
children: [
+4 -5
View File
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../l10n/app_localizations.dart';
import '../../models/payment_method.dart';
import '../../utils/money.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import '../widgets/slide_confirm.dart';
import '../../l10n/app_localizations.dart';
Future<void> confirmCloseTab(BuildContext context) async {
final viewModel = context.read<BarScreenViewModel>();
@@ -29,7 +29,7 @@ Future<void> confirmCloseTab(BuildContext context) async {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.currentTotal(tab.formattedTotal)),
Text(l10n.currentTotal(tab.totalInCents.asEuro)),
const SizedBox(height: 20),
Text(
l10n.paymentMethod,
@@ -68,8 +68,7 @@ Future<void> confirmCloseTab(BuildContext context) async {
try {
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
+2 -5
View File
@@ -1,6 +1,5 @@
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';
@@ -18,8 +17,7 @@ Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
try {
await context.read<BarScreenViewModel>().renameTab(tab.id, name.trim());
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
@@ -107,8 +105,7 @@ Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
try {
await context.read<BarScreenViewModel>().deleteTab(tab.id);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
+3 -4
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../l10n/app_localizations.dart';
import '../../viewmodels/bar_screen_view_model.dart';
Future<void> showNewTabDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context);
@@ -44,8 +44,7 @@ Future<void> showNewTabDialog(BuildContext context) async {
try {
await context.read<BarScreenViewModel>().createTab(name.trim());
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
+6 -6
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:kooltab2/views/widgets/closed_tab_card.dart';
import 'package:kooltab2/views/widgets/open_tab_history_card.dart';
import 'package:provider/provider.dart';
import '../app/router.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/bar_tab.dart';
import '../models/closed_tab.dart';
import '../utils/navigation.dart';
import '../viewmodels/history_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import 'widgets/closed_tab_card.dart';
import 'widgets/open_tab_history_card.dart';
class HistoryScreenView extends StatefulWidget {
const HistoryScreenView({super.key});
@@ -87,7 +87,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
);
}
if (viewModel.errorMessage != null) {
if (viewModel.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -101,7 +101,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
),
const SizedBox(height: 12),
Text(
l10n.localizedError(viewModel.errorMessage),
l10n.errorText(viewModel.error),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
+9 -8
View File
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/pin_lock_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/app_error.dart';
import '../viewmodels/pin_lock_view_model.dart';
enum PinEntryMode { unlock, create }
@@ -18,7 +19,7 @@ class PinEntryView extends StatefulWidget {
final bool isAdmin;
final Future<bool> Function(String)? onVerify;
final Future<bool> Function(String)? onSet;
final String? Function()? errorMessage;
final AppError? Function()? error;
final bool showBack;
const PinEntryView({
@@ -28,7 +29,7 @@ class PinEntryView extends StatefulWidget {
this.isAdmin = false,
this.onVerify,
this.onSet,
this.errorMessage,
this.error,
this.showBack = false,
});
@@ -123,7 +124,7 @@ class _PinEntryViewState extends State<PinEntryView> {
_firstEntry = null;
_isConfirmStep = false;
});
_fail(l10n.localizedError(_errorMessage()));
_fail(l10n.errorText(_error()));
}
return;
}
@@ -136,7 +137,7 @@ class _PinEntryViewState extends State<PinEntryView> {
if (ok) {
widget.onSuccess?.call();
} else {
_fail(l10n.localizedError(_errorMessage()));
_fail(l10n.errorText(_error()));
}
}
@@ -154,11 +155,11 @@ class _PinEntryViewState extends State<PinEntryView> {
return context.read<PinLockViewModel>().verify(pin);
}
String? _errorMessage() {
final callback = widget.errorMessage;
AppError? _error() {
final callback = widget.error;
if (callback != null) return callback();
return context.read<PinLockViewModel>().errorMessage;
return context.read<PinLockViewModel>().error;
}
void _fail(String message) {
+5 -9
View File
@@ -5,13 +5,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/product.dart';
import '../viewmodels/product_list_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/product.dart';
import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart';
class ProductFormView extends StatefulWidget {
final String? productId;
@@ -84,8 +83,7 @@ class _ProductFormViewState extends State<ProductFormView> {
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
setState(() => _isLoading = false);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
@@ -205,8 +203,7 @@ class _ProductFormViewState extends State<ProductFormView> {
imagePath: _imagePath,
);
}
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!mounted) return;
setState(() => _isSaving = false);
ScaffoldMessenger.of(
@@ -251,8 +248,7 @@ class _ProductFormViewState extends State<ProductFormView> {
try {
await viewModel.deleteProduct(widget.productId!);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
+6 -7
View File
@@ -4,10 +4,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../viewmodels/product_list_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../utils/money.dart';
import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart';
class ProductListView extends StatefulWidget {
const ProductListView({super.key});
@@ -22,7 +23,7 @@ class _ProductListViewState extends State<ProductListView> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ProductListViewModel>().loadProducts();
context.read<ProductListViewModel>().ensureLoaded();
});
}
@@ -51,10 +52,8 @@ class _ProductListViewState extends State<ProductListView> {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.errorMessage != null) {
return Center(
child: Text(l10n.localizedError(viewModel.errorMessage)),
);
if (viewModel.loadError != null) {
return Center(child: Text(l10n.errorText(viewModel.loadError)));
}
if (viewModel.products.isEmpty) {
@@ -93,7 +92,7 @@ class _ProductListViewState extends State<ProductListView> {
subtitle: Text(
l10n.productStockSummary(
l10n.categoryLabel(product.category),
product.formattedPrice,
product.priceInCents.asEuro,
product.stockQuantity,
),
),
+48 -26
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../viewmodels/registration_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import '../l10n/app_localizations.dart';
class RegistrationView extends StatefulWidget {
const RegistrationView({super.key});
@@ -115,8 +114,10 @@ class _RegistrationViewState extends State<RegistrationView> {
vm.errorMessage ?? 'Unknown error',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
FilledButton.icon(
@@ -150,21 +151,27 @@ class _RegistrationViewState extends State<RegistrationView> {
'Select your venue to link this device',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
Text(
'Select venue',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.15),
),
borderRadius: BorderRadius.circular(12),
),
@@ -176,7 +183,10 @@ class _RegistrationViewState extends State<RegistrationView> {
child: InkWell(
onTap: () => vm.selectVenue(venue),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Icon(
@@ -192,7 +202,9 @@ class _RegistrationViewState extends State<RegistrationView> {
),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
@@ -235,8 +247,10 @@ class _RegistrationViewState extends State<RegistrationView> {
'Enter device details to complete pairing',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
TextFormField(
@@ -246,7 +260,9 @@ class _RegistrationViewState extends State<RegistrationView> {
hintText: 'e.g. Bar terminal 1',
border: OutlineInputBorder(),
),
validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null,
validator: (v) => v == null || v.trim().isEmpty
? 'Device name is required'
: null,
),
const SizedBox(height: 16),
TextFormField(
@@ -259,7 +275,9 @@ class _RegistrationViewState extends State<RegistrationView> {
),
maxLength: 6,
textCapitalization: TextCapitalization.characters,
validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : null,
validator: (v) => v == null || v.trim().length < 6
? 'Enter the 6-character code'
: null,
),
const SizedBox(height: 8),
if (vm.errorMessage != null)
@@ -289,7 +307,9 @@ class _RegistrationViewState extends State<RegistrationView> {
if (!_formKey.currentState!.validate()) return;
await vm.registerDevice(
deviceName: _deviceNameController.text.trim(),
registrationCode: _regCodeController.text.trim().toUpperCase(),
registrationCode: _regCodeController.text
.trim()
.toUpperCase(),
);
},
child: const Text('Register'),
@@ -338,27 +358,29 @@ class _RegistrationViewState extends State<RegistrationView> {
'${vm.selectedVenue?.name ?? "Venue"} is now linked.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
'Open tabs will sync to the dashboard.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
context.read<RemoteSyncViewModel>().startSync();
context.go('/bar');
},
// Once configured, the router redirects to /bar by itself.
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
icon: const Icon(Icons.arrow_forward),
label: const Text('Start using KoolTab'),
label: const Text('Start using KoolTap'),
),
],
);
}
}
}
+68 -88
View File
@@ -7,14 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/settings.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../utils/app_update_util.dart';
import '../utils/navigation.dart';
import '../viewmodels/pin_lock_view_model.dart';
import '../viewmodels/settings_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/settings.dart';
import '../utils/app_update_util.dart';
import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/pin_lock_view_model.dart';
import '../viewmodels/settings_view_model.dart';
class SettingsScreenView extends StatefulWidget {
const SettingsScreenView({super.key});
@@ -41,7 +41,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
Future<String> currentVersion() async {
final info = await PackageInfo.fromPlatform();
debugPrint("Current app version: ${info.version}");
debugPrint('Current app version: ${info.version}');
return info.version;
}
@@ -110,6 +110,30 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
);
}
Future<T?> _pickOption<T>(
BuildContext context, {
required List<T> options,
required T selected,
required String Function(T) label,
}) {
return showModalBottomSheet<T>(
context: context,
builder: (sheetContext) => SafeArea(
child: RadioGroup<T>(
groupValue: selected,
onChanged: (value) => Navigator.pop(sheetContext, value),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final option in options)
RadioListTile<T>(value: option, title: Text(label(option))),
],
),
),
),
);
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
@@ -119,6 +143,8 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
Future<void> _enablePin() async {
final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>();
final settingsViewModel = context.read<SettingsViewModel>();
final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits);
if (pin == null) return;
@@ -127,26 +153,20 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
return;
}
final pinLockViewModel = context.read<PinLockViewModel>();
final success = await pinLockViewModel.setPin(pin);
if (!success) {
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
_showError(l10n.errorText(pinLockViewModel.error));
return;
}
pinLockViewModel.setPinRequired(true);
try {
await context.read<SettingsViewModel>().updatePinRequired(true);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
_showError(l10n.couldNotSavePin);
}
await settingsViewModel.updatePinRequired(true);
}
Future<void> _changePin() async {
final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>();
final current = await _promptPin(l10n.enterCurrentPin);
if (current == null) return;
@@ -158,38 +178,32 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
return;
}
final pinLockViewModel = context.read<PinLockViewModel>();
final success = await pinLockViewModel.changePin(
currentPin: current,
newPin: newPin,
);
if (!success) {
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
_showError(l10n.errorText(pinLockViewModel.error));
}
}
Future<void> _disablePin() async {
final l10n = AppLocalizations.of(context);
final pinLockViewModel = context.read<PinLockViewModel>();
final settingsViewModel = context.read<SettingsViewModel>();
final current = await _promptPin(l10n.enterCurrentPin);
if (current == null) return;
final pinLockViewModel = context.read<PinLockViewModel>();
final success = await pinLockViewModel.disablePin(current);
if (!success) {
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
_showError(l10n.errorText(pinLockViewModel.error));
return;
}
pinLockViewModel.setPinRequired(false);
try {
await context.read<SettingsViewModel>().updatePinRequired(false);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
_showError(l10n.couldNotSavePin);
}
await settingsViewModel.updatePinRequired(false);
}
Future<void> _configureAdminPin() async {
@@ -207,7 +221,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final success = await adminPinViewModel.setPin(pin);
if (!success) {
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
_showError(l10n.errorText(adminPinViewModel.error));
return;
}
@@ -235,7 +249,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
);
if (!success) {
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
_showError(l10n.errorText(adminPinViewModel.error));
return;
}
@@ -330,26 +344,15 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
AppLanguage.dutch => l10n.languageDutch,
},
onTap: () async {
final selected = await showModalBottomSheet<AppLanguage>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: AppLanguage.values.map((language) {
return RadioListTile<AppLanguage>(
value: language,
groupValue: settings.language,
title: Text(switch (language) {
AppLanguage.system => l10n.languageSystem,
AppLanguage.english => l10n.languageEnglish,
AppLanguage.dutch => l10n.languageDutch,
}),
onChanged: (value) =>
Navigator.pop(context, value),
);
}).toList(),
),
),
final selected = await _pickOption(
context,
options: AppLanguage.values,
selected: settings.language,
label: (language) => switch (language) {
AppLanguage.system => l10n.languageSystem,
AppLanguage.english => l10n.languageEnglish,
AppLanguage.dutch => l10n.languageDutch,
},
);
if (selected != null) {
@@ -362,23 +365,11 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
title: l10n.productGridRows,
subtitle: l10n.rowsCount(settings.barGridRows),
onTap: () async {
final selected = await showModalBottomSheet<int>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final rows in [2, 3, 4, 5, 6])
RadioListTile<int>(
value: rows,
groupValue: settings.barGridRows,
title: Text(l10n.rowsCount(rows)),
onChanged: (value) =>
Navigator.pop(context, value),
),
],
),
),
final selected = await _pickOption(
context,
options: const [2, 3, 4, 5, 6],
selected: settings.barGridRows,
label: l10n.rowsCount,
);
if (selected != null) {
@@ -396,27 +387,16 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
AppThemeMode.ugly => l10n.ugly,
},
onTap: () async {
final selected = await showModalBottomSheet<AppThemeMode>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: AppThemeMode.values.map((mode) {
return RadioListTile<AppThemeMode>(
value: mode,
groupValue: settings.themeMode,
title: Text(switch (mode) {
AppThemeMode.system => l10n.system,
AppThemeMode.light => l10n.light,
AppThemeMode.dark => l10n.dark,
AppThemeMode.ugly => l10n.ugly,
}),
onChanged: (value) =>
Navigator.pop(context, value),
);
}).toList(),
),
),
final selected = await _pickOption(
context,
options: AppThemeMode.values,
selected: settings.themeMode,
label: (mode) => switch (mode) {
AppThemeMode.system => l10n.system,
AppThemeMode.light => l10n.light,
AppThemeMode.dark => l10n.dark,
AppThemeMode.ugly => l10n.ugly,
},
);
if (selected != null) {
@@ -484,7 +464,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
_showUpdateDialog(update);
} catch (e, stack) {
Sentry.captureException(e, stackTrace: stack);
unawaited(Sentry.captureException(e, stackTrace: stack));
if (!mounted) return;
ScaffoldMessenger.of(
+3 -3
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../utils/app_update_util.dart';
import '../utils/navigation.dart';
import '../viewmodels/update_progress_view_model.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
class UpdateProgressView extends StatefulWidget {
final UpdateInfo update;
@@ -166,7 +166,7 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
),
),
const SizedBox(height: 24),
Text('KoolTab', style: Theme.of(context).textTheme.headlineMedium),
Text('KoolTap', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(
l10n.version(widget.update.version),
+7 -5
View File
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:kooltab2/models/closed_tab.dart';
import 'package:kooltab2/models/closed_tab_item.dart';
import 'package:kooltab2/models/payment_method.dart';
import '../../l10n/app_localizations.dart';
import '../../models/closed_tab.dart';
import '../../models/closed_tab_item.dart';
import '../../models/payment_method.dart';
import '../../utils/history_grouping.dart';
import '../../utils/money.dart';
class ClosedTabCard extends StatefulWidget {
final ClosedTab closedTab;
@@ -103,7 +104,8 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
switch (closedTab.paymentMethod) {
PaymentMethod.cash => l10n.cash,
PaymentMethod.payconiq => l10n.payconiq,
PaymentMethod.paymentPending => l10n.paymentPending,
PaymentMethod.paymentPending =>
l10n.paymentPending,
},
style: TextStyle(
fontSize: 11,
@@ -126,7 +128,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
),
const SizedBox(width: 8),
Text(
closedTab.formattedTotal,
closedTab.totalInCents.asEuro,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:intl/intl.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item_purchase.dart';
import '../../utils/money.dart';
class OpenTabHistoryCard extends StatefulWidget {
final BarTab tab;
@@ -133,7 +134,7 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
),
const SizedBox(width: 8),
Text(
tab.formattedTotal,
tab.totalInCents.asEuro,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
+1 -1
View File
@@ -1,9 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:kooltab2/models/product.dart';
import '../../l10n/app_localizations.dart';
import '../../models/product.dart';
class ProductTile extends StatelessWidget {
final Product product;