Files
kooltab/lib/views/dialogs/open_tab_dialog.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;
}
}