Files
kooltab/lib/views/registration_view.dart
T
2026-10-26 22:18:56 +01:00

520 lines
17 KiB
Dart

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<RegistrationView> createState() => _RegistrationViewState();
}
class _RegistrationViewState extends State<RegistrationView> {
final _deviceNameController = TextEditingController(text: 'Bar terminal 1');
final _regCodeController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<RegistrationViewModel>().fetchVenues();
});
}
@override
void dispose() {
_deviceNameController.dispose();
_regCodeController.dispose();
super.dispose();
}
Future<void> _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<RemoteSyncViewModel>().activate());
context.popOrGo('/admin');
}
@override
Widget build(BuildContext context) {
final vm = context.watch<RegistrationViewModel>();
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());
}
}