Files
kooltab/lib/views/bar_screen_view.dart
T
2026-07-11 17:47:40 +02:00

579 lines
16 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/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(
actions: [
IconButton(
tooltip: 'Manage products',
onPressed: () => context.go('/products'),
icon: const Icon(Icons.inventory_2_outlined),
),
IconButton(
tooltip: 'Refresh',
onPressed: viewModel.load,
icon: const Icon(Icons.refresh),
),
],
),
resizeToAvoidBottomInset: false,
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.errorMessage != null) {
return Center(child: Text(viewModel.errorMessage!));
}
return Row(
children: [
Expanded(
flex: 3,
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);
},
),
),
const VerticalDivider(width: 1),
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',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
Navigator.of(dialogContext).pop(value);
},
),
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'
'Payments are not handled yet, so this only marks the tab as closed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Close tab'),
),
],
);
},
);
if (confirmed != true) return;
await viewModel.closeSelectedTab();
}
}
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) {
if (products.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory_2_outlined, size: 48),
const SizedBox(height: 12),
const Text('No products yet.'),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => context.go('/products/new'),
icon: const Icon(Icons.add),
label: const Text('Add product'),
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final columns = ((constraints.maxWidth / 170).floor())
.clamp(3, 6)
.toInt();
return GridView.builder(
padding: const EdgeInsets.all(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) {
return Card(
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: enabled ? onTap : null,
child: Opacity(
opacity: enabled ? 1 : 0.45,
child: _hasImage
? Image.file(
File(product.imagePath!),
fit: BoxFit.scaleDown,
width: double.infinity,
height: double.infinity,
)
: const Center(
child: Stack(
children: [
Icon(Icons.image_not_supported_outlined, size: 42),
Text("No Image found!"),
],
),
),
),
),
);
}
}
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 filteredTabs = widget.tabs.where((tab) {
return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
}).toList();
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: widget.onNewTabPressed,
icon: const Icon(Icons.add),
label: const Text('Open tab'),
),
),
const SizedBox(height: 12),
TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: const InputDecoration(
hintText: 'Search name...',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Open tabs',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
SizedBox(
height: 200,
child: _OpenTabsList(
tabs: filteredTabs,
selectedTabId: widget.selectedTabId,
onTabSelected: widget.onTabSelected,
onTabClosed: widget.onTabClosed,
),
),
const Divider(height: 24),
Expanded(
child: widget.selectedTab == null
? const Center(child: Text('Select or open a tab.'))
: _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 const Center(child: Text('No open tabs.'));
}
return ListView.separated(
itemCount: tabs.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final tab = tabs[index];
final selected = tab.id == selectedTabId;
return ClipRRect(
child: Slidable(
key: ValueKey(tab.id),
endActionPane: ActionPane(
motion: const DrawerMotion(),
extentRatio: 0.65,
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,
label: 'Close',
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
],
),
child: Card(
margin: EdgeInsets.zero,
color: selected
? Theme.of(context).colorScheme.primaryContainer
: null,
child: ListTile(
dense: true,
selected: selected,
title: Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('${tab.itemCount} items • ${tab.formattedTotal}'),
onTap: () => onTabSelected(tab.id),
),
),
),
);
},
);
}
}
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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tab.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'Total: ${tab.formattedTotal}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Expanded(
child: tab.items.isEmpty
? const Center(child: Text('Tap products to add them.'))
: 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 Divider(height: 24),
Row(
children: [
Expanded(
child: Text(
tab.formattedTotal,
style: Theme.of(context).textTheme.headlineSmall,
),
),
FilledButton(
onPressed: onCloseTabPressed,
child: const Text('Close'),
),
],
),
],
);
}
}
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) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${item.quantity} × ${item.formattedUnitPrice}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => onQuantityChanged(item, item.quantity - 1),
icon: const Icon(Icons.remove_circle_outline),
),
Text('${item.quantity}'),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => onQuantityChanged(item, item.quantity + 1),
icon: const Icon(Icons.add_circle_outline),
),
SizedBox(
width: 72,
child: Text(item.formattedLineTotal, textAlign: TextAlign.end),
),
],
),
);
}
}