fix: misselanious updates

This commit is contained in:
2026-10-26 22:18:56 +01:00
parent 71af6f9ffe
commit 65e5e286b0
9 changed files with 477 additions and 233 deletions
+346 -223
View File
@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
@@ -6,6 +9,8 @@ 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});
@@ -26,8 +31,6 @@ class _RegistrationViewState extends State<RegistrationView> {
});
}
AppLocalizations get _l10n => AppLocalizations.of(context);
@override
void dispose() {
_deviceNameController.dispose();
@@ -35,6 +38,23 @@ class _RegistrationViewState extends State<RegistrationView> {
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>();
@@ -44,11 +64,17 @@ class _RegistrationViewState extends State<RegistrationView> {
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: IconButton(
tooltip: l10n.cancel,
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.close_rounded),
),
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(
@@ -56,7 +82,15 @@ class _RegistrationViewState extends State<RegistrationView> {
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: _buildStep(vm, l10n),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_StepIndicator(current: _stepIndex(vm)),
const SizedBox(height: 32),
_buildStep(vm, l10n),
],
),
),
),
),
@@ -64,333 +98,422 @@ class _RegistrationViewState extends State<RegistrationView> {
);
}
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();
return _buildLoading(l10n);
case RegistrationStep.error:
return _buildError(vm);
return _buildError(vm, l10n);
case RegistrationStep.venuesLoaded:
case RegistrationStep.registering:
if (vm.selectedVenue == null) {
return _buildVenueSelection(vm, l10n);
}
return _buildDeviceDetails(vm, l10n);
case RegistrationStep.registering:
return _buildRegistering();
case RegistrationStep.registered:
return _buildSuccess(vm, l10n);
case RegistrationStep.initial:
return _buildLoading();
}
}
Widget _buildLoading() {
Widget _buildLoading(AppLocalizations l10n) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
Icon(
Icons.cloud_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
const SizedBox(height: 40),
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 16),
Text(
_l10n.connectingToServer,
l10n.connectingToServer,
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildError(RegistrationViewModel vm) {
Widget _buildError(RegistrationViewModel vm, AppLocalizations l10n) {
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
Icon(
Icons.cloud_off_rounded,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
Icon(Icons.cloud_off_rounded, size: 56, color: scheme.error),
const SizedBox(height: 16),
Text(
_l10n.failedToConnect,
l10n.failedToConnect,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
const SizedBox(height: 8),
Text(
vm.errorMessage ?? _l10n.unknownError,
vm.errorMessage ?? l10n.unknownError,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
color: scheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => vm.retry(),
onPressed: vm.retry,
icon: const Icon(Icons.refresh),
label: Text(_l10n.retry),
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: [
const SizedBox(height: 40),
Icon(
Icons.cloud_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
_l10n.connectToRemoteServer,
textAlign: TextAlign.center,
l10n.selectVenue,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
_l10n.selectVenueToLink,
textAlign: TextAlign.center,
l10n.selectVenueToLink,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
Text(
_l10n.selectVenue,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.15),
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
for (final venue in vm.venues)
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => vm.selectVenue(venue),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Icon(
Icons.store_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
venue.name,
style: Theme.of(context).textTheme.bodyLarge,
),
),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
),
],
color: scheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () => vm.retry(),
child: Text(_l10n.refreshVenues),
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: [
const SizedBox(height: 40),
Icon(
Icons.store_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
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),
Text(
vm.selectedVenue!.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
_l10n.enterDeviceDetails,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
TextFormField(
controller: _deviceNameController,
enabled: !isBusy,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: _l10n.deviceName,
hintText: 'e.g. Bar terminal 1',
border: OutlineInputBorder(),
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,
v == null || v.trim().isEmpty ? l10n.deviceNameRequired : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _regCodeController,
decoration: InputDecoration(
labelText: _l10n.registrationCode,
hintText: '6-character code from dashboard',
border: OutlineInputBorder(),
counterText: '',
),
maxLength: 6,
enabled: !isBusy,
autofocus: true,
maxLength: _codeLength,
textAlign: TextAlign.center,
textCapitalization: TextCapitalization.characters,
validator: (v) => v == null || v.trim().length < 6
? _l10n.enterRegistrationCode
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,
),
const SizedBox(height: 8),
if (vm.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
vm.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
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: 16),
Row(
children: [
TextButton(
onPressed: () {
vm.reset();
},
child: Text(_l10n.back),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () async {
if (!_formKey.currentState!.validate()) return;
await vm.registerDevice(
deviceName: _deviceNameController.text.trim(),
registrationCode: _regCodeController.text
.trim()
.toUpperCase(),
);
},
child: Text(_l10n.register),
),
),
],
],
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 _buildRegistering() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 24),
Text(
_l10n.registeringDevice,
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) {
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
Icon(
Icons.check_circle_rounded,
size: 80,
color: Theme.of(context).colorScheme.primary,
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,
l10n.deviceRegistered,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Text(
'${vm.selectedVenue?.name ?? "Venue"} is now linked.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
_l10n.openTabsWillSync,
l10n.venueNowLinked(vm.selectedVenue?.name ?? ''),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
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(
// Once configured, the router redirects to /bar by itself.
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
onPressed: _finish,
icon: const Icon(Icons.arrow_forward),
label: Text(_l10n.done),
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());
}
}