feat: add remote pull and push
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user