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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user