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
+113 -1
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
class AdminScreenView extends StatelessWidget {
const AdminScreenView({super.key});
@@ -15,9 +16,39 @@ class AdminScreenView extends StatelessWidget {
adminPinViewModel.lock();
}
Future<void> _confirmUnlink(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect from server'),
content: const Text(
'This will unlink the device from the remote server. '
'Open tabs will no longer be synced. Continue?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true && context.mounted) {
context.read<RemoteSyncViewModel>().unlink();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final remoteSync = context.watch<RemoteSyncViewModel>();
return PopScope(
onPopInvokedWithResult: (didPop, result) {
@@ -68,6 +99,81 @@ class AdminScreenView extends StatelessWidget {
),
],
),
const SizedBox(height: 8),
_AdminSection(
title: 'Remote Server',
children: [
if (remoteSync.isConfigured) ...[
_AdminTile(
icon: Icons.cloud_done_outlined,
title: 'Connected',
subtitle: 'Device linked to remote server',
trailing: TextButton(
onPressed: () => _confirmUnlink(context),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Disconnect'),
),
onTap: null,
),
] else
_AdminTile(
icon: Icons.cloud_outlined,
title: 'Not connected',
subtitle: 'Link to remote dashboard',
onTap: () => context.push('/register'),
),
],
),
if (remoteSync.isConfigured) ...[
const SizedBox(height: 8),
_AdminSection(
title: l10n.remoteActivity,
children: [
_AdminTile(
icon: Icons.sync,
title: l10n.syncNow,
subtitle: remoteSync.isSyncing
? l10n.syncing
: remoteSync.lastSyncAt != null
? l10n.lastSync(remoteSync.lastSyncAt!.toLocal().toString().substring(0, 19))
: l10n.notSyncedYet,
trailing: remoteSync.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: () => remoteSync.syncNow(),
),
],
),
if (remoteSync.activity.isNotEmpty)
_AdminSection(
title: l10n.remoteActivityHistory,
children: remoteSync.activity.map((entry) {
return ListTile(
dense: true,
leading: Icon(
entry.result == 'completed'
? Icons.check_circle_outline
: entry.result == 'conflict'
? Icons.warning_amber_outlined
: Icons.error_outline,
size: 20,
),
title: Text(
'${entry.commandType} (${entry.commandId.substring(0, 8)}...)',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${entry.processedAt.toLocal()} - ${entry.result}',
style: const TextStyle(fontSize: 11),
),
);
}).toList(),
),
],
],
),
),
@@ -121,12 +227,14 @@ class _AdminTile extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
const _AdminTile({
required this.icon,
required this.title,
this.subtitle,
this.trailing,
required this.onTap,
});
@@ -169,6 +277,10 @@ class _AdminTile extends StatelessWidget {
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
if (onTap != null)
Icon(
Icons.chevron_right_rounded,
@@ -180,4 +292,4 @@ class _AdminTile extends StatelessWidget {
),
);
}
}
}
+39 -1
View File
@@ -15,6 +15,7 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import '../models/bar_tab.dart';
import '../models/product.dart';
import '../models/tab_item.dart';
import '../models/remote_command.dart';
import '../services/product_service.dart';
import '../utils/app_updater.dart';
import '../viewmodels/bar_screen_view_model.dart';
@@ -38,6 +39,43 @@ class _BarScreenViewState extends State<BarScreenView> {
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkRemoteEvent();
}
@override
void didUpdateWidget(covariant BarScreenView oldWidget) {
super.didUpdateWidget(oldWidget);
_checkRemoteEvent();
}
void _checkRemoteEvent() {
final viewModel = context.read<BarScreenViewModel>();
final event = viewModel.lastRemoteEvent;
if (event == null) return;
viewModel.clearRemoteEvent();
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
final message = l10n.tabClosedRemotely(
event.tabCustomerName ?? '',
event.requestedBy ?? '',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -83,7 +121,7 @@ class _BarScreenViewState extends State<BarScreenView> {
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => viewModel.load(),
onPressed: () => viewModel.loadAndSync(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
+17 -3
View File
@@ -83,7 +83,11 @@ class _PaymentPicker extends StatelessWidget {
const _PaymentPicker({required this.selected, required this.onChanged});
static const _options = [PaymentMethod.cash, PaymentMethod.payconiq];
static const _options = [
PaymentMethod.cash,
PaymentMethod.payconiq,
PaymentMethod.paymentPending,
];
@override
Widget build(BuildContext context) {
@@ -93,7 +97,11 @@ class _PaymentPicker extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: _options.map((option) {
final value = option;
final label = value == PaymentMethod.cash ? l10n.cash : l10n.payconiq;
final label = switch (value) {
PaymentMethod.cash => l10n.cash,
PaymentMethod.payconiq => l10n.payconiq,
PaymentMethod.paymentPending => l10n.paymentPending,
};
final isSelected = selected == value;
return Padding(
@@ -111,7 +119,7 @@ class _PaymentPicker extends StatelessWidget {
size: 16,
color: Colors.lightGreen,
)
else
else if (value == PaymentMethod.payconiq)
SvgPicture.asset(
'assets/icons/payconic.svg',
width: 16,
@@ -120,6 +128,12 @@ class _PaymentPicker extends StatelessWidget {
Colors.pinkAccent,
BlendMode.srcIn,
),
)
else
const Icon(
Icons.hourglass_empty,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 6),
Text(label),
+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'),
),
],
);
}
}
+2
View File
@@ -23,6 +23,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
return switch (method) {
PaymentMethod.payconiq => Icons.qr_code_rounded,
PaymentMethod.cash => Icons.money_rounded,
PaymentMethod.paymentPending => Icons.hourglass_empty,
};
}
@@ -102,6 +103,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
switch (closedTab.paymentMethod) {
PaymentMethod.cash => l10n.cash,
PaymentMethod.payconiq => l10n.payconiq,
PaymentMethod.paymentPending => l10n.paymentPending,
},
style: TextStyle(
fontSize: 11,