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 showOpenTabDialog(BuildContext context) async { final viewModel = context.read(); 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 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 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 _addCustomer( BuildContext context, { required String initialName, }) async { final l10n = AppLocalizations.of(context); final nameController = TextEditingController(text: initialName); final emailController = TextEditingController(); final formKey = GlobalKey(); 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(); 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; } }