import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../utils/navigation.dart'; import '../viewmodels/registration_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; const _codeLength = 6; class RegistrationView extends StatefulWidget { const RegistrationView({super.key}); @override State createState() => _RegistrationViewState(); } class _RegistrationViewState extends State { final _deviceNameController = TextEditingController(text: 'Bar terminal 1'); final _regCodeController = TextEditingController(); final _formKey = GlobalKey(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().fetchVenues(); }); } @override void dispose() { _deviceNameController.dispose(); _regCodeController.dispose(); super.dispose(); } Future _register(RegistrationViewModel vm) async { if (!_formKey.currentState!.validate()) return; FocusScope.of(context).unfocus(); await vm.registerDevice( deviceName: _deviceNameController.text.trim(), registrationCode: _regCodeController.text.trim().toUpperCase(), ); } void _finish() { // The redirect away from /register only sees the base location of a // pushed route, so leave the screen ourselves. unawaited(context.read().activate()); context.popOrGo('/admin'); } @override Widget build(BuildContext context) { final vm = context.watch(); final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( backgroundColor: Colors.transparent, title: Text(l10n.connectToRemoteServer), leading: vm.step == RegistrationStep.registered ? null : IconButton( tooltip: l10n.cancel, onPressed: vm.step == RegistrationStep.registering ? null : () => context.popOrGo('/admin'), icon: const Icon(Icons.close_rounded), ), automaticallyImplyLeading: false, ), body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _StepIndicator(current: _stepIndex(vm)), const SizedBox(height: 32), _buildStep(vm, l10n), ], ), ), ), ), ), ); } int _stepIndex(RegistrationViewModel vm) { if (vm.step == RegistrationStep.registered) return 2; return vm.selectedVenue == null ? 0 : 1; } Widget _buildStep(RegistrationViewModel vm, AppLocalizations l10n) { switch (vm.step) { case RegistrationStep.initial: case RegistrationStep.loadingVenues: return _buildLoading(l10n); case RegistrationStep.error: return _buildError(vm, l10n); case RegistrationStep.venuesLoaded: case RegistrationStep.registering: if (vm.selectedVenue == null) { return _buildVenueSelection(vm, l10n); } return _buildDeviceDetails(vm, l10n); case RegistrationStep.registered: return _buildSuccess(vm, l10n); } } Widget _buildLoading(AppLocalizations l10n) { return Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 40), const CircularProgressIndicator(strokeWidth: 3), const SizedBox(height: 16), Text( l10n.connectingToServer, style: Theme.of(context).textTheme.titleMedium, ), ], ); } Widget _buildError(RegistrationViewModel vm, AppLocalizations l10n) { final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.cloud_off_rounded, size: 56, color: scheme.error), const SizedBox(height: 16), Text( l10n.failedToConnect, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( vm.errorMessage ?? l10n.unknownError, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 24), FilledButton.icon( onPressed: vm.retry, icon: const Icon(Icons.refresh), label: Text(l10n.retry), ), ], ); } Widget _buildVenueSelection(RegistrationViewModel vm, AppLocalizations l10n) { final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.selectVenue, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 4), Text( l10n.selectVenueToLink, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 24), if (vm.venues.isEmpty) Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(16), ), child: Column( children: [ Icon( Icons.storefront_outlined, size: 40, color: scheme.onSurface.withValues(alpha: 0.4), ), const SizedBox(height: 12), Text( l10n.noVenuesFound, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ) else for (final venue in vm.venues) Padding( padding: const EdgeInsets.only(bottom: 8), child: Material( color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => vm.selectVenue(venue), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), child: Row( children: [ CircleAvatar( backgroundColor: scheme.primaryContainer, foregroundColor: scheme.onPrimaryContainer, child: const Icon(Icons.storefront_outlined), ), const SizedBox(width: 16), Expanded( child: Text( venue.name, style: Theme.of(context).textTheme.titleMedium, ), ), Icon( Icons.chevron_right_rounded, color: scheme.onSurface.withValues(alpha: 0.4), ), ], ), ), ), ), ), const SizedBox(height: 16), Center( child: TextButton.icon( onPressed: vm.retry, icon: const Icon(Icons.refresh, size: 18), label: Text(l10n.refreshVenues), ), ), ], ); } Widget _buildDeviceDetails(RegistrationViewModel vm, AppLocalizations l10n) { final scheme = Theme.of(context).colorScheme; final isBusy = vm.step == RegistrationStep.registering; return Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), decoration: BoxDecoration( color: scheme.primaryContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Icon(Icons.storefront_outlined, color: scheme.primary), const SizedBox(width: 12), Expanded( child: Text( vm.selectedVenue!.name, style: Theme.of(context).textTheme.titleMedium, ), ), TextButton( onPressed: isBusy ? null : vm.deselectVenue, child: Text(l10n.changeVenue), ), ], ), ), const SizedBox(height: 24), Text( l10n.enterDeviceDetails, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 16), TextFormField( controller: _deviceNameController, enabled: !isBusy, textInputAction: TextInputAction.next, decoration: InputDecoration( labelText: l10n.deviceName, hintText: l10n.deviceNameHint, prefixIcon: const Icon(Icons.tablet_android_outlined), border: const OutlineInputBorder(), ), validator: (v) => v == null || v.trim().isEmpty ? l10n.deviceNameRequired : null, ), const SizedBox(height: 16), TextFormField( controller: _regCodeController, enabled: !isBusy, autofocus: true, maxLength: _codeLength, textAlign: TextAlign.center, textCapitalization: TextCapitalization.characters, textInputAction: TextInputAction.done, autocorrect: false, enableSuggestions: false, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp('[a-zA-Z0-9]')), _UpperCaseFormatter(), ], style: Theme.of(context).textTheme.headlineSmall?.copyWith( letterSpacing: 8, fontFamily: 'monospace', ), decoration: InputDecoration( labelText: l10n.registrationCode, hintText: '•' * _codeLength, helperText: l10n.registrationCodeHelper, border: const OutlineInputBorder(), ), onFieldSubmitted: (_) => _register(vm), validator: (v) => v == null || v.trim().length < _codeLength ? l10n.enterRegistrationCode : null, ), if (vm.errorMessage != null) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: scheme.errorContainer, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(Icons.error_outline, color: scheme.onErrorContainer), const SizedBox(width: 12), Expanded( child: Text( vm.errorMessage!, style: TextStyle(color: scheme.onErrorContainer), ), ), ], ), ), ], const SizedBox(height: 24), FilledButton( style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), ), onPressed: isBusy ? null : () => _register(vm), child: isBusy ? Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 12), Text(l10n.registeringDevice), ], ) : Text(l10n.register), ), ], ), ); } Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) { final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: scheme.primaryContainer, shape: BoxShape.circle, ), child: Icon( Icons.check_rounded, size: 56, color: scheme.onPrimaryContainer, ), ), ), const SizedBox(height: 24), Text( l10n.deviceRegistered, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( l10n.venueNowLinked(vm.selectedVenue?.name ?? ''), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 4), Text( l10n.openTabsWillSync, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 32), FilledButton.icon( style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)), onPressed: _finish, icon: const Icon(Icons.arrow_forward), label: Text(l10n.done), ), ], ); } } class _StepIndicator extends StatelessWidget { final int current; const _StepIndicator({required this.current}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final scheme = Theme.of(context).colorScheme; final labels = [l10n.stepVenue, l10n.stepDevice, l10n.stepLinked]; return Row( children: [ for (var i = 0; i < labels.length; i++) ...[ if (i > 0) Expanded( child: Container( height: 2, margin: const EdgeInsets.only(bottom: 20), color: i <= current ? scheme.primary : scheme.onSurface.withValues(alpha: 0.15), ), ), Column( mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 14, backgroundColor: i <= current ? scheme.primary : scheme.onSurface.withValues(alpha: 0.1), foregroundColor: i <= current ? scheme.onPrimary : scheme.onSurface.withValues(alpha: 0.6), child: i < current ? const Icon(Icons.check_rounded, size: 16) : Text( '${i + 1}', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), const SizedBox(height: 4), Text( labels[i], style: Theme.of(context).textTheme.labelSmall?.copyWith( color: i <= current ? scheme.onSurface : scheme.onSurface.withValues(alpha: 0.5), ), ), ], ), ], ], ); } } class _UpperCaseFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { return newValue.copyWith(text: newValue.text.toUpperCase()); } }