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:
+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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user