Files
kooltab/lib/views/bar_screen_view.dart
T

869 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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 '../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
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.manageProducts,
onPressed: () => context.go('/products'),
icon: const Icon(Icons.inventory_2_outlined),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.tabHistory,
onPressed: () => context.go('/history'),
icon: const Icon(Icons.history_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => viewModel.load(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.settings,
onPressed: () => context.go('/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();
},
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),
),
),
],
);
},
),
);
}
}
// ---------------------------------------------------------------------------
// Product grid
// ---------------------------------------------------------------------------
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.go('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
],
),
);
}
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),
);
},
);
},
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Tab panel
// ---------------------------------------------------------------------------
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(
// A step darker/lighter than the main surface, whichever direction
// the active theme goes — matches how it read against the dark
// surface color originally.
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: 18),
Row(
children: [
Text(
l10n.openTabs.toUpperCase(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${filteredTabs.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
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),
),
),
],
),
);
}
}