329 lines
11 KiB
Dart
329 lines
11 KiB
Dart
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;
|
|
}
|
|
}
|