feat: New Open Tab dialog with search and optional customer email. Drinks are now grouped per order. Payments trigger cha-ching and money rain.

This commit is contained in:
2026-10-28 02:11:32 +01:00
parent 1aee0541c4
commit 29ceb51177
41 changed files with 2771 additions and 812 deletions
+172 -135
View File
@@ -18,6 +18,7 @@ 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/open_tab_dialog.dart';
import '../widgets/venue_title.dart';
import 'product_grid.dart';
import 'tab_panel.dart';
@@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget {
}
class _BarScreenViewState extends State<BarScreenView> {
static const _idleTimeout = Duration(minutes: 5);
bool _showSearch = false;
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
Timer? _idleTimer;
@override
void initState() {
@@ -41,6 +45,7 @@ class _BarScreenViewState extends State<BarScreenView> {
.read<RemoteSyncViewModel>()
.tabsClosedRemotely
.listen(_showRemoteClose);
_restartIdleTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context);
@@ -50,9 +55,19 @@ class _BarScreenViewState extends State<BarScreenView> {
@override
void dispose() {
_remoteCloseSubscription.cancel();
_idleTimer?.cancel();
super.dispose();
}
/// A tab left open after the bar goes quiet is easy to add to by mistake,
/// so it is put back in the list.
void _restartIdleTimer() {
_idleTimer?.cancel();
_idleTimer = Timer(_idleTimeout, () {
if (mounted) context.read<BarScreenViewModel>().deselectTab();
});
}
void _showRemoteClose(RemoteSyncEvent event) {
if (!mounted) return;
@@ -91,6 +106,16 @@ class _BarScreenViewState extends State<BarScreenView> {
}
}
Future<void> _openTab() async {
final l10n = AppLocalizations.of(context);
final name = await showOpenTabDialog(context);
if (name == null || !mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name))));
}
void _showSyncProblem({required String title, required String description}) {
final l10n = AppLocalizations.of(context);
showDialog<void>(
@@ -151,150 +176,162 @@ class _BarScreenViewState extends State<BarScreenView> {
(viewModel) => viewModel.venueName,
);
return Scaffold(
appBar: AppBar(
title: VenueTitle(
text: venueName == null || venueName.trim().isEmpty
? l10n.barTabs
: venueName,
),
actions: [
IconButton(
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
isSelected: _showSearch,
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search_off_rounded),
// Any touch counts as activity, wherever it lands
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartIdleTimer(),
child: Scaffold(
appBar: AppBar(
title: VenueTitle(
text: venueName == null || venueName.trim().isEmpty
? l10n.barTabs
: venueName,
),
const SizedBox(width: 6),
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',
actions: [
IconButton(
tooltip: l10n.openTab,
onPressed: _openTab,
icon: const Icon(Icons.person_add_alt_1_outlined),
),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.deviceAccessRevoked,
color: Theme.of(context).colorScheme.error,
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
isSelected: _showSearch,
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search_off_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
],
if (context.watch<RemoteSyncViewModel>().updateRequired
case final message?) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.appUpdateRequired,
color: Theme.of(context).colorScheme.error,
onPressed: () => _showUpdateRequired(message),
icon: const Icon(Icons.system_update_rounded),
),
],
// A failing sync is otherwise invisible: the bar keeps working
// offline while nothing from the website arrives
if (context.watch<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
if (kDebugMode) ...[
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.manageProducts,
icon: Icons.inventory_2_outlined,
location: '/products',
),
],
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,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
const SizedBox(width: 6),
_DebugShortcut(
tooltip: l10n.tabHistory,
icon: Icons.history_rounded,
location: '/history',
),
],
);
},
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.deviceAccessRevoked,
color: Theme.of(context).colorScheme.error,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded),
),
],
if (context.watch<RemoteSyncViewModel>().updateRequired
case final message?) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.appUpdateRequired,
color: Theme.of(context).colorScheme.error,
onPressed: () => _showUpdateRequired(message),
icon: const Icon(Icons.system_update_rounded),
),
],
// A failing sync is otherwise invisible: the bar keeps working
// offline while nothing from the website arrives
if (context.watch<RemoteSyncViewModel>().lastError
case final error?) ...[
const SizedBox(width: 6),
// Offline is normal for a bar; anything else needs attention
if (context.watch<RemoteSyncViewModel>().isOffline)
IconButton(
tooltip: l10n.offline,
onPressed: () => _showSyncProblem(
title: l10n.offline,
description: l10n.offlineDescription,
),
icon: const Icon(Icons.wifi_off_rounded),
)
else
IconButton(
tooltip: '${l10n.syncProblem}: $error',
color: Theme.of(context).colorScheme.error,
onPressed: () => _showSyncProblem(
title: l10n.syncProblem,
description: '${l10n.syncProblemDescription}\n\n$error',
),
icon: const Icon(Icons.sync_problem_rounded),
),
],
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,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onTabDeselected: viewModel.deselectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context),
onTabEdited: (tab) => showEditTabDialog(context, tab),
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
),
),
],
);
},
),
),
);
}