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:
@@ -8,7 +8,6 @@ import '../models/remote_command.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'dialogs/new_tab_dialog.dart';
|
||||
|
||||
class AdminScreenView extends StatelessWidget {
|
||||
const AdminScreenView({super.key});
|
||||
@@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget {
|
||||
adminPinViewModel.lock();
|
||||
}
|
||||
|
||||
Future<void> _openNewTab(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final name = await showNewTabDialog(context);
|
||||
|
||||
if (name == null || !context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name))));
|
||||
}
|
||||
|
||||
Future<void> _confirmUnlink(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget {
|
||||
_AdminSection(
|
||||
title: l10n.adminTools,
|
||||
children: [
|
||||
_AdminTile(
|
||||
icon: Icons.person_add_alt_1_outlined,
|
||||
title: l10n.openNewTab,
|
||||
subtitle: l10n.openNewTabDescription,
|
||||
onTap: () => _openNewTab(context),
|
||||
),
|
||||
_AdminTile(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: l10n.products,
|
||||
|
||||
+172
-135
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import '../widgets/order_divider.dart';
|
||||
|
||||
/// Changes how many of [item] are on the tab: [delta] > 0 rings up more,
|
||||
/// [delta] < 0 takes drinks of order [orderId] off.
|
||||
typedef ItemQuantityChanged =
|
||||
Future<void> Function(TabItem item, int delta, String? orderId);
|
||||
|
||||
class SelectedTabDetails extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final ItemQuantityChanged onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
|
||||
const SelectedTabDetails({
|
||||
@@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
: 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,
|
||||
);
|
||||
},
|
||||
: _OrderList(
|
||||
// Another tab starts with its own orders folded
|
||||
key: ValueKey(tab.id),
|
||||
orders: ordersOfTab(tab),
|
||||
onItemQuantityChanged: onItemQuantityChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -102,13 +103,82 @@ class SelectedTabDetails extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab's orders, oldest first, each under the time it started.
|
||||
class _OrderList extends StatefulWidget {
|
||||
final List<TabOrder<TabItem>> orders;
|
||||
final ItemQuantityChanged onItemQuantityChanged;
|
||||
|
||||
const _OrderList({
|
||||
super.key,
|
||||
required this.orders,
|
||||
required this.onItemQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OrderList> createState() => _OrderListState();
|
||||
}
|
||||
|
||||
/// The current (latest) order is open, earlier ones are folded away under
|
||||
/// their time and total; tapping a header flips it.
|
||||
class _OrderListState extends State<_OrderList> {
|
||||
/// Orders opened or closed by hand, by [_key].
|
||||
final _toggled = <String, bool>{};
|
||||
|
||||
String _key(TabOrder<TabItem> order) =>
|
||||
order.orderId ?? order.startedAt.toIso8601String();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final orders = widget.orders;
|
||||
final children = <Widget>[];
|
||||
for (final (orderIndex, order) in orders.indexed) {
|
||||
final key = _key(order);
|
||||
final expanded = _toggled[key] ?? orderIndex == orders.length - 1;
|
||||
children.add(
|
||||
OrderDivider(
|
||||
key: ValueKey('order-$key'),
|
||||
startedAt: order.startedAt,
|
||||
totalInCents: order.rows.fold<int>(
|
||||
0,
|
||||
(total, row) => total + row.quantity * row.line.unitPriceInCents,
|
||||
),
|
||||
expanded: expanded,
|
||||
onTap: () => setState(() => _toggled[key] = !expanded),
|
||||
),
|
||||
);
|
||||
if (!expanded) continue;
|
||||
|
||||
for (final (index, row) in order.rows.indexed) {
|
||||
if (index > 0) children.add(const Divider(height: 1));
|
||||
children.add(
|
||||
TabItemRow(
|
||||
key: ValueKey('${order.orderId ?? order.startedAt}-${row.line.id}'),
|
||||
item: row.line,
|
||||
quantity: row.quantity,
|
||||
orderId: order.orderId,
|
||||
onQuantityChanged: widget.onItemQuantityChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ListView(children: children);
|
||||
}
|
||||
}
|
||||
|
||||
class TabItemRow extends StatelessWidget {
|
||||
final TabItem item;
|
||||
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
||||
|
||||
/// How many of [item] are in this order.
|
||||
final int quantity;
|
||||
final String? orderId;
|
||||
final ItemQuantityChanged onQuantityChanged;
|
||||
|
||||
const TabItemRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.quantity,
|
||||
required this.orderId,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
try {
|
||||
await onQuantityChanged(item, delta);
|
||||
await onQuantityChanged(item, delta, orderId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${item.quantity} × ${item.unitPriceInCents.asEuro}',
|
||||
'$quantity × ${item.unitPriceInCents.asEuro}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Text(
|
||||
'${item.quantity}',
|
||||
'$quantity',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
@@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
item.lineTotalInCents.asEuro,
|
||||
(quantity * item.unitPriceInCents).asEuro,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/name_index.dart';
|
||||
@@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget {
|
||||
final String? selectedTabId;
|
||||
final bool showSearch;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onTabDeselected;
|
||||
final ItemQuantityChanged onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
@@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget {
|
||||
required this.selectedTabId,
|
||||
required this.showSearch,
|
||||
required this.onTabSelected,
|
||||
required this.onTabDeselected,
|
||||
required this.onItemQuantityChanged,
|
||||
required this.onCloseTabPressed,
|
||||
required this.onTabEdited,
|
||||
@@ -77,13 +78,23 @@ class _TabPanelState extends State<TabPanel> {
|
||||
/// The names-list offset the selected tile was last lined up at.
|
||||
double? _attachedOffset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
/// The first row of the names list, measured for [_rowExtent].
|
||||
final GlobalKey _firstRowKey = GlobalKey();
|
||||
|
||||
// The row height is only known once the list is laid out
|
||||
/// The height of every row in the names list, gap included. Measured, as
|
||||
/// a short list can't tell it from its scroll range.
|
||||
double? _rowExtent;
|
||||
|
||||
/// The row height is only known once the list is laid out (and changes
|
||||
/// with the text size).
|
||||
void _measureRow() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
if (box.size.height != _rowExtent) {
|
||||
setState(() => _rowExtent = box.size.height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,11 +110,12 @@ class _TabPanelState extends State<TabPanel> {
|
||||
if (tab == null || _focus == _PanelFocus.names) return null;
|
||||
|
||||
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
|
||||
final extent = _tabExtent(tabs.length);
|
||||
final extent = _rowExtent;
|
||||
if (index == -1 || extent == null) return null;
|
||||
|
||||
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
|
||||
return offset < 0 ? null : offset;
|
||||
// Rounding can leave a sliver below zero
|
||||
return offset < -0.5 ? null : max(offset, 0);
|
||||
}
|
||||
|
||||
void _lineUpSelectedTab(double? offset) {
|
||||
@@ -152,12 +164,12 @@ class _TabPanelState extends State<TabPanel> {
|
||||
_searchQuery = '';
|
||||
}
|
||||
|
||||
// Adding products means working on the selected tab
|
||||
// Adding products, or opening a tab from the Open Tab dialog, means
|
||||
// working on the selected tab
|
||||
final tab = widget.selectedTab;
|
||||
final oldTab = oldWidget.selectedTab;
|
||||
if (tab != null &&
|
||||
tab.id == oldTab?.id &&
|
||||
tab.itemCount != oldTab?.itemCount) {
|
||||
(tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) {
|
||||
_focus = _PanelFocus.items;
|
||||
}
|
||||
}
|
||||
@@ -169,14 +181,6 @@ class _TabPanelState extends State<TabPanel> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Every tab row has the same height, so it follows from the scroll range.
|
||||
double? _tabExtent(int tabCount) {
|
||||
if (!_tabsScrollController.hasClients || tabCount == 0) return null;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
return (position.maxScrollExtent + position.viewportDimension) / tabCount;
|
||||
}
|
||||
|
||||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||||
/// lands on the next one.
|
||||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||||
@@ -186,8 +190,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
setState(() => _touchedLetter = letter);
|
||||
}
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
if (extent == null) return;
|
||||
final extent = _rowExtent;
|
||||
if (extent == null || !_tabsScrollController.hasClients) return;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
final start = indexLetters.indexOf(letter);
|
||||
@@ -205,8 +209,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
String? _visibleLetter(List<BarTab> tabs) {
|
||||
if (tabs.isEmpty) return null;
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
final index = extent == null
|
||||
final extent = _rowExtent;
|
||||
final index = extent == null || !_tabsScrollController.hasClients
|
||||
? 0
|
||||
: (_tabsScrollController.offset / extent).round().clamp(
|
||||
0,
|
||||
@@ -226,6 +230,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
_searchQuery.toLowerCase(),
|
||||
);
|
||||
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
|
||||
_measureRow();
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||||
@@ -236,7 +241,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: _tabsScrollController,
|
||||
builder: (context, child) => _LetterIndexBar(
|
||||
builder: (context, child) => LetterIndexBar(
|
||||
availableLetters: {
|
||||
for (final tab in filteredTabs)
|
||||
indexLetterOf(tab.customerName),
|
||||
@@ -262,11 +267,30 @@ class _TabPanelState extends State<TabPanel> {
|
||||
? _PanelFocus.balanced
|
||||
: _focus;
|
||||
final searchHeight = widget.showSearch ? _searchHeight : 0.0;
|
||||
final namesHeight = switch (focus) {
|
||||
_PanelFocus.balanced => min(246.0, available / 2),
|
||||
_PanelFocus.names => available - collapsedItems,
|
||||
_PanelFocus.items => collapsedNames + searchHeight,
|
||||
};
|
||||
// Without a selected tab there are no items to show, so
|
||||
// the names get the whole panel
|
||||
final hasSelection = widget.selectedTab != null;
|
||||
final selectedIndex = filteredTabs.indexWhere(
|
||||
(tab) => tab.id == widget.selectedTabId,
|
||||
);
|
||||
final rowExtent = _rowExtent;
|
||||
final namesHeight = !hasSelection
|
||||
? constraints.maxHeight
|
||||
: switch (focus) {
|
||||
_PanelFocus.balanced => min(246.0, available / 2),
|
||||
_PanelFocus.names => available - collapsedItems,
|
||||
// A tile near the top can't scroll down to its
|
||||
// items, so the names shrink up to it instead
|
||||
_PanelFocus.items =>
|
||||
(selectedIndex == -1 || rowExtent == null
|
||||
? collapsedNames
|
||||
: min(
|
||||
collapsedNames,
|
||||
(selectedIndex + 1) * rowExtent -
|
||||
_tabRowGap,
|
||||
)) +
|
||||
searchHeight,
|
||||
};
|
||||
|
||||
final attachOffset = _attachOffset(
|
||||
filteredTabs,
|
||||
@@ -335,12 +359,19 @@ class _TabPanelState extends State<TabPanel> {
|
||||
},
|
||||
child: OpenTabsList(
|
||||
controller: _tabsScrollController,
|
||||
firstRowKey: _firstRowKey,
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
attachedTabId: attached
|
||||
? widget.selectedTabId
|
||||
: null,
|
||||
onTabSelected: (id) {
|
||||
// Tapping the open tab again closes it
|
||||
if (id == widget.selectedTabId) {
|
||||
_focusOn(_PanelFocus.names);
|
||||
widget.onTabDeselected();
|
||||
return;
|
||||
}
|
||||
_focusOn(_PanelFocus.items);
|
||||
widget.onTabSelected(id);
|
||||
},
|
||||
@@ -352,7 +383,8 @@ class _TabPanelState extends State<TabPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!attached) const Divider(height: dividerHeight),
|
||||
if (!attached && hasSelection)
|
||||
const Divider(height: dividerHeight),
|
||||
Expanded(
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _focusOn(_PanelFocus.items),
|
||||
@@ -434,6 +466,9 @@ class _TabPanelState extends State<TabPanel> {
|
||||
|
||||
class OpenTabsList extends StatelessWidget {
|
||||
final ScrollController? controller;
|
||||
|
||||
/// Put on the first row, so its height can be measured.
|
||||
final Key? firstRowKey;
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
|
||||
@@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
const OpenTabsList({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.firstRowKey,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
this.attachedTabId,
|
||||
@@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
final attached = tab.id == attachedTabId;
|
||||
|
||||
return _TabRow(
|
||||
key: index == 0 ? firstRowKey : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: attached
|
||||
? _attachedTileRadius
|
||||
@@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
class _TabRow extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TabRow({required this.child});
|
||||
const _TabRow({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -603,14 +640,16 @@ class _TabCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LetterIndexBar extends StatefulWidget {
|
||||
/// The A–Z strip beside a list of names: tap or slide to jump to a letter.
|
||||
class LetterIndexBar extends StatefulWidget {
|
||||
final Set<String> availableLetters;
|
||||
final String? activeLetter;
|
||||
final bool isTouched;
|
||||
final ValueChanged<String> onLetterSelected;
|
||||
final VoidCallback onReleased;
|
||||
|
||||
const _LetterIndexBar({
|
||||
const LetterIndexBar({
|
||||
super.key,
|
||||
required this.availableLetters,
|
||||
required this.activeLetter,
|
||||
required this.isTouched,
|
||||
@@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LetterIndexBar> createState() => _LetterIndexBarState();
|
||||
State<LetterIndexBar> createState() => _LetterIndexBarState();
|
||||
}
|
||||
|
||||
class _LetterIndexBarState extends State<_LetterIndexBar>
|
||||
class _LetterIndexBarState extends State<LetterIndexBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static final _zIndex = indexLetters.indexOf('Z');
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import '../widgets/money_rain.dart';
|
||||
import '../widgets/slide_confirm.dart';
|
||||
|
||||
Future<void> confirmCloseTab(BuildContext context) async {
|
||||
@@ -73,6 +77,9 @@ Future<void> confirmCloseTab(BuildContext context) async {
|
||||
|
||||
try {
|
||||
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
|
||||
_playChaChing();
|
||||
// Make it rain, but only once in a hundred tabs.
|
||||
if (context.mounted && Random().nextInt(25) == 0) showMoneyRain(context);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -81,6 +88,16 @@ Future<void> confirmCloseTab(BuildContext context) async {
|
||||
}
|
||||
}
|
||||
|
||||
final _chaChingPlayer = AudioPlayer();
|
||||
|
||||
/// Plays the till sound once a tab is paid; a sound that fails to play never
|
||||
/// gets in the way of closing the tab.
|
||||
void _playChaChing() {
|
||||
_chaChingPlayer
|
||||
.play(AssetSource('Cha-Ching.mp3'))
|
||||
.catchError((Object e) => debugPrint('Could not play cha-ching: $e'));
|
||||
}
|
||||
|
||||
const _payconiqQrAsset = 'assets/payconiq/qr.png';
|
||||
|
||||
/// Shows the Payconiq QR code for the customer to scan. Resolves to true
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import 'duplicate_tab_name_dialog.dart';
|
||||
|
||||
/// Returns the name of the opened tab, or null when nothing was opened.
|
||||
Future<String?> showNewTabDialog(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final controller = TextEditingController();
|
||||
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.openNewTab),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.customerGroupName),
|
||||
onSubmitted: (value) {
|
||||
Navigator.of(dialogContext).pop(value);
|
||||
},
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(controller.text);
|
||||
},
|
||||
child: Text(l10n.openTab),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final trimmed = name?.trim() ?? '';
|
||||
if (trimmed.isEmpty || !context.mounted) return null;
|
||||
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
if (viewModel.hasOpenTabNamed(trimmed) &&
|
||||
!await confirmDuplicateTabName(context, trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await viewModel.createTab(trimmed);
|
||||
return trimmed;
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../utils/name_index.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import '../bar/tab_panel.dart';
|
||||
import 'duplicate_tab_name_dialog.dart';
|
||||
|
||||
/// What the Open Tab dialog was closed with.
|
||||
sealed class _Choice {}
|
||||
|
||||
class _ExistingTab extends _Choice {
|
||||
final BarTab tab;
|
||||
|
||||
_ExistingTab(this.tab);
|
||||
}
|
||||
|
||||
class _NewCustomer extends _Choice {
|
||||
/// The search text, as a start for the name.
|
||||
final String name;
|
||||
|
||||
_NewCustomer(this.name);
|
||||
}
|
||||
|
||||
/// Lets the bartender pick a tab that isn't open yet, or add a new customer.
|
||||
/// Returns the name of the opened tab, or null when nothing was opened.
|
||||
Future<String?> showOpenTabDialog(BuildContext context) async {
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
|
||||
final choice = await showDialog<_Choice>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _OpenTabDialog(viewModel: viewModel),
|
||||
);
|
||||
|
||||
if (!context.mounted) return null;
|
||||
|
||||
switch (choice) {
|
||||
case null:
|
||||
return null;
|
||||
case _ExistingTab(:final tab):
|
||||
viewModel.openTab(tab.id);
|
||||
return tab.customerName;
|
||||
case _NewCustomer(:final name):
|
||||
return _addCustomer(context, initialName: name);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenTabDialog extends StatefulWidget {
|
||||
final BarScreenViewModel viewModel;
|
||||
|
||||
const _OpenTabDialog({required this.viewModel});
|
||||
|
||||
@override
|
||||
State<_OpenTabDialog> createState() => _OpenTabDialogState();
|
||||
}
|
||||
|
||||
/// Every row is this tall, so the letter index knows where each name is.
|
||||
const _rowExtent = 64.0;
|
||||
|
||||
class _OpenTabDialogState extends State<_OpenTabDialog> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String _query = '';
|
||||
|
||||
/// The letter under the finger while the index bar is pressed.
|
||||
String? _touchedLetter;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||||
/// lands on the next one.
|
||||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||||
if (letter != _touchedLetter) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _touchedLetter = letter);
|
||||
}
|
||||
if (!_scrollController.hasClients) return;
|
||||
|
||||
final position = _scrollController.position;
|
||||
final start = indexLetters.indexOf(letter);
|
||||
final index = tabs.indexWhere(
|
||||
(tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start,
|
||||
);
|
||||
|
||||
_scrollController.jumpTo(
|
||||
index == -1
|
||||
? position.maxScrollExtent
|
||||
: min(index * _rowExtent, position.maxScrollExtent),
|
||||
);
|
||||
}
|
||||
|
||||
String? _visibleLetter(List<BarTab> tabs) {
|
||||
if (tabs.isEmpty) return null;
|
||||
final offset = _scrollController.hasClients ? _scrollController.offset : 0;
|
||||
final index = (offset / _rowExtent).round().clamp(0, tabs.length - 1);
|
||||
return indexLetterOf(tabs[index].customerName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.openTab),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
height: 480,
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchByName,
|
||||
prefixIcon: const Icon(Icons.search_rounded, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
// Tabs opened or closed elsewhere while the dialog is up
|
||||
child: ListenableBuilder(
|
||||
listenable: widget.viewModel,
|
||||
builder: (context, _) {
|
||||
final query = _query.trim().toLowerCase();
|
||||
final available = widget.viewModel.closedTabs;
|
||||
final tabs =
|
||||
available
|
||||
.where(
|
||||
(tab) =>
|
||||
tab.customerName.toLowerCase().contains(query),
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
compareByIndex(a.customerName, b.customerName),
|
||||
);
|
||||
|
||||
if (tabs.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
available.isEmpty ? l10n.allTabsOpen : l10n.noTabsFound,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final list = ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: tabs.length,
|
||||
itemExtent: _rowExtent,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: scheme.primary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
foregroundColor: scheme.primary,
|
||||
child: Text(indexLetterOf(tab.customerName)),
|
||||
),
|
||||
title: Text(
|
||||
tab.customerName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: tab.email == null
|
||||
? null
|
||||
: Text(
|
||||
tab.email!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pop(_ExistingTab(tab)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: _scrollController,
|
||||
builder: (context, _) => LetterIndexBar(
|
||||
availableLetters: {
|
||||
for (final tab in tabs)
|
||||
indexLetterOf(tab.customerName),
|
||||
},
|
||||
activeLetter: _touchedLetter ?? _visibleLetter(tabs),
|
||||
isTouched: _touchedLetter != null,
|
||||
onLetterSelected: (letter) =>
|
||||
_jumpToLetter(letter, tabs),
|
||||
onReleased: () =>
|
||||
setState(() => _touchedLetter = null),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: list),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(_NewCustomer(_query.trim())),
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
label: Text(l10n.newCustomer),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
|
||||
/// Asks for a name and an optional email, then opens a tab for them.
|
||||
Future<String?> _addCustomer(
|
||||
BuildContext context, {
|
||||
required String initialName,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final nameController = TextEditingController(text: initialName);
|
||||
final emailController = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final customer = await showDialog<({String name, String? email})>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
void submit() {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
final email = emailController.text.trim();
|
||||
Navigator.of(dialogContext).pop((
|
||||
name: nameController.text.trim(),
|
||||
email: email.isEmpty ? null : email,
|
||||
));
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.newCustomer),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.customerGroupName,
|
||||
),
|
||||
validator: (value) =>
|
||||
(value ?? '').trim().isEmpty ? l10n.nameRequired : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(labelText: l10n.emailOptional),
|
||||
validator: (value) {
|
||||
final email = (value ?? '').trim();
|
||||
return email.isEmpty || _emailPattern.hasMatch(email)
|
||||
? null
|
||||
: l10n.invalidEmail;
|
||||
},
|
||||
onFieldSubmitted: (_) => submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(onPressed: submit, child: Text(l10n.openTab)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (customer == null || !context.mounted) return null;
|
||||
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
if (viewModel.hasOpenTabNamed(customer.name) &&
|
||||
!await confirmDuplicateTabName(context, customer.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await viewModel.createTab(customer.name, email: customer.email);
|
||||
return customer.name;
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import '../../models/closed_tab.dart';
|
||||
import '../../models/closed_tab_item.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/history_grouping.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import 'order_history_list.dart';
|
||||
|
||||
class ClosedTabCard extends StatefulWidget {
|
||||
final ClosedTab closedTab;
|
||||
@@ -35,10 +36,6 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final dateFormat = DateFormat.yMMMd(locale).add_jm();
|
||||
final dayGroups = groupClosedTabItemsByDay(
|
||||
closedTab.items,
|
||||
fallbackDate: closedTab.closedAt,
|
||||
);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
@@ -159,16 +156,12 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
for (var index = 0; index < dayGroups.length; index++)
|
||||
_ClosedTabDayDropdown(
|
||||
key: ValueKey(
|
||||
'${dayGroups[index].items.first.closedTabId}-'
|
||||
'${dayGroups[index].day.toIso8601String()}',
|
||||
),
|
||||
group: dayGroups[index],
|
||||
fallbackDate: closedTab.closedAt,
|
||||
initiallyExpanded: index == 0,
|
||||
),
|
||||
OrderHistoryList(
|
||||
orders: ordersOfClosedTab(closedTab),
|
||||
productName: (ClosedTabItem item) => item.productName,
|
||||
unitPriceInCents: (ClosedTabItem item) =>
|
||||
item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -179,213 +172,3 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClosedTabDayDropdown extends StatefulWidget {
|
||||
final ClosedTabItemDayGroup group;
|
||||
final DateTime fallbackDate;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
const _ClosedTabDayDropdown({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.fallbackDate,
|
||||
required this.initiallyExpanded,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ClosedTabDayDropdown> createState() => _ClosedTabDayDropdownState();
|
||||
}
|
||||
|
||||
class _ClosedTabDayDropdownState extends State<_ClosedTabDayDropdown> {
|
||||
late bool _expanded = widget.initiallyExpanded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final dayFormat = DateFormat.yMMMMd(locale);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
decoration: BoxDecoration(
|
||||
color: _expanded
|
||||
? scheme.primary.withValues(alpha: 0.08)
|
||||
: scheme.onSurface.withValues(alpha: 0.035),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _expanded
|
||||
? scheme.primary.withValues(alpha: 0.28)
|
||||
: scheme.onSurface.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 16,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dayFormat.format(widget.group.day),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.tabItemCount(widget.group.itemCount),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded,
|
||||
color: _expanded
|
||||
? scheme.primary
|
||||
: scheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
child: _expanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Divider(
|
||||
height: 1,
|
||||
color: scheme.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
for (final item in widget.group.items)
|
||||
_ClosedTabItemRow(
|
||||
item: item,
|
||||
purchasedAt:
|
||||
item.purchasedAt ?? widget.fallbackDate,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClosedTabItemRow extends StatelessWidget {
|
||||
final ClosedTabItem item;
|
||||
final DateTime purchasedAt;
|
||||
|
||||
const _ClosedTabItemRow({required this.item, required this.purchasedAt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final timeFormat = DateFormat.jm(locale);
|
||||
final unitPrice = NumberFormat.simpleCurrency(
|
||||
locale: locale,
|
||||
).format(item.unitPriceInCents / 100);
|
||||
final lineTotal = NumberFormat.simpleCurrency(
|
||||
locale: locale,
|
||||
).format(item.lineTotalInCents / 100);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.productName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.isRemoved
|
||||
? scheme.onSurface.withValues(alpha: 0.5)
|
||||
: scheme.onSurface,
|
||||
decoration: item.isRemoved
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
timeFormat.format(purchasedAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.isRemoved)
|
||||
Text(
|
||||
l10n.removed,
|
||||
style: TextStyle(
|
||||
color: scheme.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Text(
|
||||
'${item.quantity} × $unitPrice',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
lineTotal,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _emojis = ['💲', '🤑', '💸', '💰', '💵', '💶', '💷'];
|
||||
const _dropCount = 160;
|
||||
const _duration = Duration(seconds: 3);
|
||||
|
||||
/// Lets money emojis rain down over the whole screen for three seconds, for
|
||||
/// when a tab gets paid. Taps go straight through to the app underneath.
|
||||
void showMoneyRain(BuildContext context) {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _MoneyRain(onDone: () => entry.remove()),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
}
|
||||
|
||||
class _MoneyRain extends StatefulWidget {
|
||||
final VoidCallback onDone;
|
||||
|
||||
const _MoneyRain({required this.onDone});
|
||||
|
||||
@override
|
||||
State<_MoneyRain> createState() => _MoneyRainState();
|
||||
}
|
||||
|
||||
class _MoneyRainState extends State<_MoneyRain>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _duration,
|
||||
)..forward().whenComplete(widget.onDone);
|
||||
|
||||
final _drops = List.generate(_dropCount, (_) => _Drop.random(Random()));
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: _MoneyRainPainter(_controller, _drops),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One falling emoji. Times are fractions of the whole rain, so every drop
|
||||
/// has left the screen by the time the rain ends.
|
||||
class _Drop {
|
||||
final int emoji;
|
||||
final double x;
|
||||
final double start;
|
||||
final double fall;
|
||||
final double size;
|
||||
final double spin;
|
||||
final double wobble;
|
||||
|
||||
const _Drop({
|
||||
required this.emoji,
|
||||
required this.x,
|
||||
required this.start,
|
||||
required this.fall,
|
||||
required this.size,
|
||||
required this.spin,
|
||||
required this.wobble,
|
||||
});
|
||||
|
||||
factory _Drop.random(Random random) {
|
||||
final fall = 0.3 + random.nextDouble() * 0.17;
|
||||
return _Drop(
|
||||
emoji: random.nextInt(_emojis.length),
|
||||
x: random.nextDouble(),
|
||||
start: random.nextDouble() * (1 - fall),
|
||||
fall: fall,
|
||||
size: 28 + random.nextDouble() * 36,
|
||||
spin: (random.nextDouble() - 0.5) * 4 * pi,
|
||||
wobble: random.nextDouble() * 2 * pi,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MoneyRainPainter extends CustomPainter {
|
||||
static const _baseSize = 64.0;
|
||||
|
||||
final Animation<double> animation;
|
||||
final List<_Drop> drops;
|
||||
final List<TextPainter> _glyphs = [
|
||||
for (final emoji in _emojis)
|
||||
TextPainter(
|
||||
text: TextSpan(
|
||||
text: emoji,
|
||||
style: const TextStyle(fontSize: _baseSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(),
|
||||
];
|
||||
|
||||
_MoneyRainPainter(this.animation, this.drops) : super(repaint: animation);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final t = animation.value;
|
||||
|
||||
for (final drop in drops) {
|
||||
final progress = (t - drop.start) / drop.fall;
|
||||
if (progress < 0 || progress > 1) continue;
|
||||
|
||||
final glyph = _glyphs[drop.emoji];
|
||||
final scale = drop.size / _baseSize;
|
||||
final dx =
|
||||
drop.x * size.width + sin(progress * 3 * pi + drop.wobble) * 24;
|
||||
final dy = -drop.size + progress * (size.height + drop.size * 2);
|
||||
|
||||
canvas
|
||||
..save()
|
||||
..translate(dx, dy)
|
||||
..rotate(drop.spin * progress)
|
||||
..scale(scale);
|
||||
glyph.paint(canvas, Offset(-glyph.width / 2, -glyph.height / 2));
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MoneyRainPainter oldDelegate) =>
|
||||
oldDelegate.drops != drops;
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item_purchase.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/easter_eggs.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import 'order_history_list.dart';
|
||||
|
||||
class OpenTabHistoryCard extends StatefulWidget {
|
||||
final BarTab tab;
|
||||
@@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget {
|
||||
class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
List<_OpenPurchase> _purchases(BarTab tab) {
|
||||
final purchases = <_OpenPurchase>[];
|
||||
|
||||
for (final item in tab.items) {
|
||||
final records = item.purchases.isEmpty
|
||||
? List<TabItemPurchase>.generate(
|
||||
item.quantity,
|
||||
(index) => TabItemPurchase(
|
||||
id: '${item.id}-$index',
|
||||
tabItemId: item.id,
|
||||
purchasedAt: tab.openedAt,
|
||||
),
|
||||
)
|
||||
: item.purchases;
|
||||
|
||||
purchases.addAll(
|
||||
records.map(
|
||||
(purchase) => _OpenPurchase(
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
purchasedAt: purchase.purchasedAt,
|
||||
isRemoved: purchase.isRemoved,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
purchases.sort((a, b) {
|
||||
final byTime = b.purchasedAt.compareTo(a.purchasedAt);
|
||||
if (byTime != 0) return byTime;
|
||||
|
||||
return b.productName.compareTo(a.productName);
|
||||
});
|
||||
return purchases;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tab = widget.tab;
|
||||
@@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
..._purchases(
|
||||
tab,
|
||||
).map((purchase) => _OpenPurchaseRow(purchase: purchase)),
|
||||
OrderHistoryList(
|
||||
orders: ordersOfTab(tab, includeRemoved: true),
|
||||
productName: (TabItem item) => item.productName,
|
||||
unitPriceInCents: (TabItem item) =>
|
||||
item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -178,81 +147,3 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenPurchase {
|
||||
final String productName;
|
||||
final int unitPriceInCents;
|
||||
final DateTime purchasedAt;
|
||||
final bool isRemoved;
|
||||
|
||||
const _OpenPurchase({
|
||||
required this.productName,
|
||||
required this.unitPriceInCents,
|
||||
required this.purchasedAt,
|
||||
required this.isRemoved,
|
||||
});
|
||||
}
|
||||
|
||||
class _OpenPurchaseRow extends StatelessWidget {
|
||||
final _OpenPurchase purchase;
|
||||
|
||||
const _OpenPurchaseRow({required this.purchase});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final dateFormat = DateFormat.yMMMd(locale).add_jm();
|
||||
final unitPrice = NumberFormat.simpleCurrency(
|
||||
locale: locale,
|
||||
).format(purchase.unitPriceInCents / 100);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
purchase.productName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: purchase.isRemoved
|
||||
? scheme.onSurface.withValues(alpha: 0.5)
|
||||
: scheme.onSurface,
|
||||
decoration: purchase.isRemoved
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
dateFormat.format(purchase.purchasedAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (purchase.isRemoved)
|
||||
Text(
|
||||
l10n.removed,
|
||||
style: TextStyle(
|
||||
color: scheme.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'1 × $unitPrice',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
|
||||
/// The line above the drinks of one order: "—— 12:32 today ——". With
|
||||
/// [totalInCents] it adds what the order costs; with [onTap] it opens and
|
||||
/// closes the order, a chevron showing whether it is [expanded].
|
||||
class OrderDivider extends StatelessWidget {
|
||||
final DateTime startedAt;
|
||||
final int? totalInCents;
|
||||
final bool expanded;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const OrderDivider({
|
||||
super.key,
|
||||
required this.startedAt,
|
||||
this.totalInCents,
|
||||
this.expanded = true,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final line = Expanded(
|
||||
child: Divider(color: scheme.onSurface.withValues(alpha: 0.18)),
|
||||
);
|
||||
final label = [
|
||||
orderTimeLabel(l10n, locale, startedAt),
|
||||
if (totalInCents != null) totalInCents!.asEuro,
|
||||
].join(' – ');
|
||||
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface.withValues(alpha: 0.6),
|
||||
);
|
||||
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
line,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
if (onTap != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded,
|
||||
size: 18,
|
||||
color: style?.color,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
line,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) return row;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/tab_orders.dart';
|
||||
import 'order_divider.dart';
|
||||
|
||||
/// The drinks of a tab in history, per order: "2 × Duvel € 8,00", with the
|
||||
/// drinks taken off again struck through.
|
||||
class OrderHistoryList<L> extends StatelessWidget {
|
||||
final List<TabOrder<L>> orders;
|
||||
final String Function(L line) productName;
|
||||
final int Function(L line) unitPriceInCents;
|
||||
|
||||
const OrderHistoryList({
|
||||
super.key,
|
||||
required this.orders,
|
||||
required this.productName,
|
||||
required this.unitPriceInCents,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final order in orders) ...[
|
||||
OrderDivider(startedAt: order.startedAt),
|
||||
for (final row in order.rows) ...[
|
||||
if (row.quantity > 0)
|
||||
_HistoryRow(
|
||||
name: productName(row.line),
|
||||
quantity: row.quantity,
|
||||
unitPriceInCents: unitPriceInCents(row.line),
|
||||
),
|
||||
if (row.removed > 0)
|
||||
_HistoryRow(
|
||||
name: productName(row.line),
|
||||
quantity: row.removed,
|
||||
unitPriceInCents: unitPriceInCents(row.line),
|
||||
removed: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryRow extends StatelessWidget {
|
||||
final String name;
|
||||
final int quantity;
|
||||
final int unitPriceInCents;
|
||||
final bool removed;
|
||||
|
||||
const _HistoryRow({
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.unitPriceInCents,
|
||||
this.removed = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$quantity × $name',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: removed
|
||||
? scheme.onSurface.withValues(alpha: 0.5)
|
||||
: scheme.onSurface,
|
||||
decoration: removed ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (removed)
|
||||
Text(
|
||||
l10n.removedCount(quantity),
|
||||
style: TextStyle(
|
||||
color: scheme.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
(quantity * unitPriceInCents).asEuro,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user