Files
kooltab/lib/views/bar_screen_view.dart
T

902 lines
28 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:flutter/services.dart';
import 'package:flutter/widget_previews.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:provider/provider.dart';
import 'dart:io';
import 'package:flutter_slidable/flutter_slidable.dart';
import '../models/bar_tab.dart';
import '../models/product.dart';
import '../models/tab_item.dart';
import '../viewmodels/bar_screen_view_model.dart';
class BarScreenView extends StatelessWidget {
const BarScreenView({super.key});
@override
Widget build(BuildContext context) {
final viewModel = context.watch<BarScreenViewModel>();
final productsViewModel = context.watch<ProductListViewModel>();
return Scaffold(
appBar: AppBar(
title: const Text('Bar Tabs'),
actions: [
IconButton(
tooltip: 'Manage products',
onPressed: () => context.go('/products'),
icon: const Icon(Icons.inventory_2_outlined),
),
const SizedBox(width: 6),
IconButton(
tooltip: 'Tab history',
onPressed: () => context.go('/history'),
icon: const Icon(Icons.history_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: 'Refresh',
onPressed: viewModel.load,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: 'logout',
onPressed: (){
Provider.of<PinLockViewModel>(context, listen: false).lock();
},
icon: const Icon(Icons.logout)
),
const SizedBox(width: 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(
viewModel.errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
return Row(
children: [
Expanded(
flex: 2,
child: _ProductGrid(
products: productsViewModel.products,
hasSelectedTab: viewModel.selectedTab != null,
onProductTap: (product) async {
if (viewModel.selectedTab == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Open or select a tab first.'),
),
);
return;
}
await viewModel.addProductToSelectedTab(product);
},
),
),
Container(width: 1, color: Theme.of(context).dividerColor),
Expanded(
flex: 1,
child: _TabPanel(
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
onNewTabPressed: () => _showNewTabDialog(context),
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => _confirmCloseTab(context),
onTabClosed: viewModel.closeTab,
),
),
],
);
},
),
);
}
Future<void> _showNewTabDialog(BuildContext context) async {
final controller = TextEditingController();
final name = await showDialog<String>(
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<BarScreenViewModel>().createTab(name.trim());
}
Future<void> _confirmCloseTab(BuildContext context) async {
final viewModel = context.read<BarScreenViewModel>();
final tab = viewModel.selectedTab;
if (tab == null) return;
final confirmed = await showDialog<bool>(
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();
}
}
// ---------------------------------------------------------------------------
// Product grid
// ---------------------------------------------------------------------------
class _ProductGrid extends StatelessWidget {
final List<Product> products;
final bool hasSelectedTab;
final ValueChanged<Product> onProductTap;
const _ProductGrid({
required this.products,
required this.hasSelectedTab,
required this.onProductTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
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.withOpacity(0.05),
),
child: Icon(
Icons.inventory_2_outlined,
size: 40,
color: scheme.onSurface.withOpacity(0.3),
),
),
const SizedBox(height: 16),
Text(
'No products yet',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Add your first product to start selling.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => context.go('/products/new'),
icon: const Icon(Icons.add),
label: const Text('Add product'),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 4),
child: Row(
children: [
Text('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.withOpacity(0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline_rounded,
size: 14,
color: scheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Flexible(
child: Text(
'Select a tab to add items',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = ((constraints.maxWidth / 170).floor())
.clamp(3, 5)
.toInt();
return GridView.builder(
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 _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.withOpacity(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.withOpacity(0.3),
),
const SizedBox(height: 6),
Text(
'No image',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Scrim stays black regardless of theme — it's for legibility
// of the (usually light/photographic) image beneath it, not
// themed UI chrome.
if (_hasImage)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.35),
],
stops: const [0.6, 1.0],
),
),
),
),
],
),
),
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Tab panel
// ---------------------------------------------------------------------------
class _TabPanel extends StatefulWidget {
final List<BarTab> tabs;
final BarTab? selectedTab;
final String? selectedTabId;
final VoidCallback onNewTabPressed;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int quantity)
onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
final ValueChanged<String> onTabClosed;
const _TabPanel({
required this.tabs,
required this.selectedTab,
required this.selectedTabId,
required this.onNewTabPressed,
required this.onTabSelected,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
required this.onTabClosed,
});
@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 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: const InputDecoration(
hintText: 'Search by name…',
prefixIcon: Icon(Icons.search_rounded, size: 20),
isDense: true,
),
),
),
const SizedBox(width: 10),
IconButton.filled(
onPressed: widget.onNewTabPressed,
icon: const Icon(Icons.add_rounded),
tooltip: 'Open new tab',
),
],
),
const SizedBox(height: 18),
Row(
children: [
Text(
'OPEN TABS',
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.withOpacity(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,
onTabClosed: widget.onTabClosed,
),
),
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.withOpacity(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,
),
),
],
),
),
);
}
}
class _OpenTabsList extends StatelessWidget {
final List<BarTab> tabs;
final String? selectedTabId;
final ValueChanged<String> onTabSelected;
final ValueChanged<String> onTabClosed;
const _OpenTabsList({
required this.tabs,
required this.selectedTabId,
required this.onTabSelected,
required this.onTabClosed,
});
@override
Widget build(BuildContext context) {
if (tabs.isEmpty) {
return Center(
child: Text(
'No open tabs',
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: (_) {
onTabSelected(tab.id);
// TODO: rename/edit tab
},
icon: Icons.edit_outlined,
label: 'Edit',
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
SlidableAction(
onPressed: (_) => onTabClosed(tab.id),
icon: Icons.close_rounded,
label: 'Close',
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? primary.withOpacity(0.14)
: scheme.onSurface.withOpacity(0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? primary.withOpacity(0.6)
: scheme.onSurface.withOpacity(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(
'${tab.itemCount} items - ${tab.formattedTotal}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
),
),
),
);
},
);
}
}
class _SelectedTabDetails extends StatelessWidget {
final BarTab tab;
final Future<void> Function(TabItem item, int quantity) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
const _SelectedTabDetails({
required this.tab,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
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.withOpacity(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];
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.withOpacity(0.12),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total', style: Theme.of(context).textTheme.bodySmall),
Text(
tab.formattedTotal,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontSize: 24),
),
],
),
),
FilledButton(
onPressed: onCloseTabPressed,
child: const Text('Close tab'),
),
],
),
),
],
);
}
}
class _TabItemRow extends StatelessWidget {
final TabItem item;
final Future<void> Function(TabItem item, int quantity) onQuantityChanged;
const _TabItemRow({required this.item, required this.onQuantityChanged});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
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.withOpacity(0.05),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () =>
onQuantityChanged(item, item.quantity - 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: () =>
onQuantityChanged(item, item.quantity + 1),
icon: const Icon(Icons.add_rounded, size: 18),
),
],
),
),
SizedBox(
width: 72,
child: Text(
item.formattedLineTotal,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}