feat: add remote pull and push

This commit is contained in:
2026-09-23 00:19:58 +02:00
parent 9aaba69e3c
commit ccf5165417
29 changed files with 3042 additions and 24 deletions
+364
View File
@@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../viewmodels/registration_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import '../l10n/app_localizations.dart';
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();
}
@override
Widget build(BuildContext context) {
final vm = context.watch<RegistrationViewModel>();
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: _buildStep(vm, l10n),
),
),
),
),
);
}
Widget _buildStep(RegistrationViewModel vm, AppLocalizations l10n) {
switch (vm.step) {
case RegistrationStep.loadingVenues:
return _buildLoading();
case RegistrationStep.error:
return _buildError(vm);
case RegistrationStep.venuesLoaded:
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() {
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 CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 16),
Text(
'Connecting to server…',
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildError(RegistrationViewModel vm) {
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),
Text(
'Failed to connect',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
vm.errorMessage ?? 'Unknown error',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => vm.retry(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
Widget _buildVenueSelection(RegistrationViewModel vm, AppLocalizations l10n) {
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(
'Connect to remote server',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Select your venue to link this device',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
Text(
'Select venue',
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),
),
],
),
),
),
),
],
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () => vm.retry(),
child: const Text('Refresh venues'),
),
],
);
}
Widget _buildDeviceDetails(RegistrationViewModel vm, AppLocalizations l10n) {
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),
),
const SizedBox(height: 16),
Text(
vm.selectedVenue!.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Enter device details to complete pairing',
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,
decoration: const InputDecoration(
labelText: 'Device name',
hintText: 'e.g. Bar terminal 1',
border: OutlineInputBorder(),
),
validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _regCodeController,
decoration: const InputDecoration(
labelText: 'Registration code',
hintText: '6-character code from dashboard',
border: OutlineInputBorder(),
counterText: '',
),
maxLength: 6,
textCapitalization: TextCapitalization.characters,
validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : 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,
),
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () {
vm.reset();
},
child: const Text('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: const Text('Register'),
),
),
],
),
],
),
);
}
Widget _buildRegistering() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 24),
Text(
'Registering device…',
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 40),
Icon(
Icons.check_circle_rounded,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Device registered!',
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(
'Open tabs will sync to the dashboard.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
context.read<RemoteSyncViewModel>().startSync();
context.go('/bar');
},
icon: const Icon(Icons.arrow_forward),
label: const Text('Start using KoolTab'),
),
],
);
}
}