feat: add remote pull and push
This commit is contained in:
@@ -6,6 +6,7 @@ import 'app_lifecycle_lock.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import '../viewmodels/settings_view_model.dart';
|
||||
import '../theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -31,6 +32,12 @@ class _KoolTabAppState extends State<KoolTabApp> {
|
||||
context.read<PinLockViewModel>().lock();
|
||||
context.read<AdminPinViewModel>().lock();
|
||||
},
|
||||
onBackground: () {
|
||||
context.read<RemoteSyncViewModel>().pause();
|
||||
},
|
||||
onForeground: () {
|
||||
context.read<RemoteSyncViewModel>().resume();
|
||||
},
|
||||
shouldLock: () =>
|
||||
context.read<SettingsViewModel>().settings.autoLockEnabled,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../services/default_product_seeder.dart';
|
||||
import '../services/export_service.dart';
|
||||
import '../viewmodels/bar_screen_view_model.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
|
||||
class AppBootstrap extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -33,6 +34,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
|
||||
DefaultExportService(database: database).uploadToServer();
|
||||
|
||||
context.read<RemoteSyncViewModel>().init();
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||
final VoidCallback onLock;
|
||||
final VoidCallback? onBackground;
|
||||
final VoidCallback? onForeground;
|
||||
final bool Function()? shouldLock;
|
||||
|
||||
AppLifecycleLockObserver({required this.onLock, this.shouldLock});
|
||||
AppLifecycleLockObserver({
|
||||
required this.onLock,
|
||||
this.onBackground,
|
||||
this.onForeground,
|
||||
this.shouldLock,
|
||||
});
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
@@ -14,8 +21,9 @@ class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
if (shouldLock?.call() ?? true) onLock();
|
||||
onBackground?.call();
|
||||
case AppLifecycleState.resumed:
|
||||
break;
|
||||
onForeground?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -14,6 +14,7 @@ import '../views/import_names_view.dart';
|
||||
import '../views/pin_lock_view.dart';
|
||||
import '../views/product_form_view.dart';
|
||||
import '../views/product_list_view.dart';
|
||||
import '../views/registration_view.dart';
|
||||
import '../views/settings_view.dart';
|
||||
import '../views/update_progress_view.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
@@ -23,6 +24,7 @@ final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
String? _redirectApp({
|
||||
required PinLockViewModel pinLockViewModel,
|
||||
required AdminPinViewModel adminPinViewModel,
|
||||
required ValueNotifier<bool> isConfiguredNotifier,
|
||||
required GoRouterState state,
|
||||
}) {
|
||||
if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) {
|
||||
@@ -30,6 +32,18 @@ String? _redirectApp({
|
||||
}
|
||||
|
||||
final location = state.uri.path;
|
||||
final isConfigured = isConfiguredNotifier.value;
|
||||
|
||||
if (!isConfigured && location != '/register') {
|
||||
return '/register';
|
||||
}
|
||||
|
||||
if (isConfigured && location == '/register') {
|
||||
return '/bar';
|
||||
}
|
||||
|
||||
if (!isConfigured) return null;
|
||||
|
||||
final goingToLock = location == '/lock';
|
||||
final goingToAdminLock = location == '/admin/lock';
|
||||
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
||||
@@ -83,19 +97,31 @@ bool _isDebugShortcutArea(String location) {
|
||||
|
||||
GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
|
||||
required AdminPinViewModel adminPinViewModel,
|
||||
required ValueNotifier<bool> isConfiguredNotifier,
|
||||
}) {
|
||||
return GoRouter(
|
||||
initialLocation: '/bar',
|
||||
observers: [routeObserver, SentryNavigatorObserver()],
|
||||
refreshListenable: Listenable.merge([pinLockViewModel, adminPinViewModel]),
|
||||
refreshListenable: Listenable.merge([
|
||||
pinLockViewModel,
|
||||
adminPinViewModel,
|
||||
isConfiguredNotifier,
|
||||
]),
|
||||
redirect: (_, state) =>
|
||||
_redirectApp(
|
||||
pinLockViewModel: pinLockViewModel,
|
||||
adminPinViewModel: adminPinViewModel,
|
||||
isConfiguredNotifier: isConfiguredNotifier,
|
||||
state: state,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(path: '/', redirect: (context, state) => '/bar'),
|
||||
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegistrationView(),
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/lock',
|
||||
builder: (context, state) =>
|
||||
|
||||
@@ -38,6 +38,8 @@ class BarTabs extends Table {
|
||||
|
||||
DateTimeColumn get closedAt => dateTime().nullable()();
|
||||
|
||||
IntColumn get version => integer().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -138,24 +140,51 @@ class AppSettingsTable extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
@DataClassName('RemoteSyncStateRow')
|
||||
class RemoteSyncState extends Table {
|
||||
TextColumn get deviceId => text()();
|
||||
|
||||
TextColumn get serverCursor => text().nullable()();
|
||||
|
||||
DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()();
|
||||
|
||||
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
|
||||
|
||||
IntColumn get consecutiveFailureCount => integer().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {deviceId};
|
||||
}
|
||||
|
||||
@DataClassName('ProcessedRemoteCommandRow')
|
||||
class ProcessedRemoteCommands extends Table {
|
||||
TextColumn get commandId => text()();
|
||||
TextColumn get commandType => text()();
|
||||
DateTimeColumn get processedAt => dateTime()();
|
||||
TextColumn get result => text()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {commandId};
|
||||
}
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
Products,
|
||||
BarTabs,
|
||||
TabItems,
|
||||
TabItemPurchases,
|
||||
|
||||
ClosedTabs,
|
||||
ClosedTabItems,
|
||||
|
||||
AppSettingsTable,
|
||||
RemoteSyncState,
|
||||
ProcessedRemoteCommands,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 11;
|
||||
int get schemaVersion => 12;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -232,6 +261,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 11) {
|
||||
await migrator.createTable(tabItemPurchases);
|
||||
}
|
||||
|
||||
if (from < 12) {
|
||||
if (!await hasTableColumn('bar_tabs', 'version')) {
|
||||
await migrator.addColumn(barTabs, barTabs.version);
|
||||
}
|
||||
await migrator.createTable(remoteSyncState);
|
||||
await migrator.createTable(processedRemoteCommands);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -264,5 +264,15 @@
|
||||
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
||||
"paymentCash": "Cash",
|
||||
"paymentPayconiq": "Payconiq",
|
||||
"simulatedUpdateNotes": "Bug fixes and performance improvements."
|
||||
"paymentPending": "Leave unpaid",
|
||||
"simulatedUpdateNotes": "Bug fixes and performance improvements.",
|
||||
"remoteActivity": "Remote activity",
|
||||
"remoteActivityHistory": "Recent remote activity",
|
||||
"syncNow": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"lastSync": "Last sync: {time}",
|
||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||
"notSyncedYet": "Not synced yet",
|
||||
"tabClosedRemotely": "Tab {customer} closed remotely by {requester}",
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||
}
|
||||
|
||||
@@ -1580,11 +1580,59 @@ abstract class AppLocalizations {
|
||||
/// **'Payconiq'**
|
||||
String get paymentPayconiq;
|
||||
|
||||
/// No description provided for @paymentPending.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave unpaid'**
|
||||
String get paymentPending;
|
||||
|
||||
/// No description provided for @simulatedUpdateNotes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bug fixes and performance improvements.'**
|
||||
String get simulatedUpdateNotes;
|
||||
|
||||
/// No description provided for @remoteActivity.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote activity'**
|
||||
String get remoteActivity;
|
||||
|
||||
/// No description provided for @remoteActivityHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recent remote activity'**
|
||||
String get remoteActivityHistory;
|
||||
|
||||
/// No description provided for @syncNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync now'**
|
||||
String get syncNow;
|
||||
|
||||
/// No description provided for @syncing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Syncing…'**
|
||||
String get syncing;
|
||||
|
||||
/// No description provided for @lastSync.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last sync: {time}'**
|
||||
String lastSync(String time);
|
||||
|
||||
/// No description provided for @notSyncedYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not synced yet'**
|
||||
String get notSyncedYet;
|
||||
|
||||
/// No description provided for @tabClosedRemotely.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tab {customer} closed remotely by {requester}'**
|
||||
String tabClosedRemotely(String customer, String requester);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -808,6 +808,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get paymentPayconiq => 'Payconiq';
|
||||
|
||||
@override
|
||||
String get paymentPending => 'Leave unpaid';
|
||||
|
||||
@override
|
||||
String get simulatedUpdateNotes => 'Bug fixes and performance improvements.';
|
||||
|
||||
@override
|
||||
String get remoteActivity => 'Remote activity';
|
||||
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recent remote activity';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Sync now';
|
||||
|
||||
@override
|
||||
String get syncing => 'Syncing…';
|
||||
|
||||
@override
|
||||
String lastSync(String time) {
|
||||
return 'Last sync: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notSyncedYet => 'Not synced yet';
|
||||
|
||||
@override
|
||||
String tabClosedRemotely(String customer, String requester) {
|
||||
return 'Tab $customer closed remotely by $requester';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,6 +819,34 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get paymentPayconiq => 'Payconiq';
|
||||
|
||||
@override
|
||||
String get paymentPending => 'Onbetaald laten';
|
||||
|
||||
@override
|
||||
String get simulatedUpdateNotes => 'Bugfixes en prestatieverbeteringen.';
|
||||
|
||||
@override
|
||||
String get remoteActivity => 'Externe activiteit';
|
||||
|
||||
@override
|
||||
String get remoteActivityHistory => 'Recente externe activiteit';
|
||||
|
||||
@override
|
||||
String get syncNow => 'Nu synchroniseren';
|
||||
|
||||
@override
|
||||
String get syncing => 'Bezig met synchroniseren…';
|
||||
|
||||
@override
|
||||
String lastSync(String time) {
|
||||
return 'Laatste sync: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notSyncedYet => 'Nog niet gesynchroniseerd';
|
||||
|
||||
@override
|
||||
String tabClosedRemotely(String customer, String requester) {
|
||||
return 'Poef $customer extern gesloten door $requester';
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -264,5 +264,15 @@
|
||||
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
||||
"paymentCash": "Contant",
|
||||
"paymentPayconiq": "Payconiq",
|
||||
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen."
|
||||
"paymentPending": "Onbetaald laten",
|
||||
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.",
|
||||
"remoteActivity": "Externe activiteit",
|
||||
"remoteActivityHistory": "Recente externe activiteit",
|
||||
"syncNow": "Nu synchroniseren",
|
||||
"syncing": "Bezig met synchroniseren…",
|
||||
"lastSync": "Laatste sync: {time}",
|
||||
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||
"notSyncedYet": "Nog niet gesynchroniseerd",
|
||||
"tabClosedRemotely": "Poef {customer} extern gesloten door {requester}",
|
||||
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import 'package:flutter/services.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:kooltab2/services/pin_lock_service.dart';
|
||||
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||
import 'package:kooltab2/services/settings_service.dart';
|
||||
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/history_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/pin_lock_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/registration_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/settings_view_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
@@ -23,6 +25,7 @@ import 'services/product_service.dart';
|
||||
import 'viewmodels/bar_screen_view_model.dart';
|
||||
import 'viewmodels/admin_pin_view_model.dart';
|
||||
import 'viewmodels/product_list_view_model.dart';
|
||||
import 'viewmodels/remote_sync_view_model.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -48,9 +51,29 @@ Future<void> main() async {
|
||||
);
|
||||
await adminPinViewModel.ensureLoaded();
|
||||
|
||||
final remoteSyncService = RemoteSyncService(
|
||||
database: database,
|
||||
barTabService: DriftBarTabService(database: database),
|
||||
);
|
||||
|
||||
final remoteSyncViewModel = RemoteSyncViewModel(
|
||||
syncService: remoteSyncService,
|
||||
);
|
||||
|
||||
final isConfiguredNotifier = ValueNotifier<bool>(await remoteSyncService.isConfigured);
|
||||
|
||||
remoteSyncViewModel.addListener(() {
|
||||
isConfiguredNotifier.value = remoteSyncViewModel.isConfigured;
|
||||
});
|
||||
|
||||
final registrationViewModel = RegistrationViewModel(
|
||||
syncService: remoteSyncService,
|
||||
);
|
||||
|
||||
final appRouter = createAppRouter(
|
||||
pinLockViewModel,
|
||||
adminPinViewModel: adminPinViewModel,
|
||||
isConfiguredNotifier: isConfiguredNotifier,
|
||||
);
|
||||
|
||||
await SentryFlutter.init(
|
||||
@@ -83,6 +106,13 @@ Future<void> main() async {
|
||||
create: (context) =>
|
||||
DriftSettingsService(database: context.read<AppDatabase>()),
|
||||
),
|
||||
Provider<RemoteSyncService>.value(value: remoteSyncService),
|
||||
ChangeNotifierProvider<RemoteSyncViewModel>.value(
|
||||
value: remoteSyncViewModel,
|
||||
),
|
||||
ChangeNotifierProvider<RegistrationViewModel>.value(
|
||||
value: registrationViewModel,
|
||||
),
|
||||
ChangeNotifierProvider<InventoryViewModel>(
|
||||
create: (context) => InventoryViewModel(
|
||||
productService: context.read<ProductService>(),
|
||||
@@ -98,6 +128,7 @@ Future<void> main() async {
|
||||
create: (context) => BarScreenViewModel(
|
||||
barTabService: context.read<BarTabService>(),
|
||||
inventory: context.read<InventoryViewModel>(),
|
||||
remoteSyncViewModel: context.read<RemoteSyncViewModel>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<HistoryViewModel>(
|
||||
|
||||
@@ -7,6 +7,7 @@ class BarTab {
|
||||
final String status;
|
||||
final DateTime openedAt;
|
||||
final DateTime? closedAt;
|
||||
final int version;
|
||||
final List<TabItem> items;
|
||||
|
||||
const BarTab({
|
||||
@@ -14,6 +15,7 @@ class BarTab {
|
||||
required this.customerName,
|
||||
required this.status,
|
||||
required this.openedAt,
|
||||
this.version = 0,
|
||||
required this.items,
|
||||
this.closedAt,
|
||||
});
|
||||
@@ -41,6 +43,7 @@ class BarTab {
|
||||
status == other.status &&
|
||||
openedAt == other.openedAt &&
|
||||
closedAt == other.closedAt &&
|
||||
version == other.version &&
|
||||
listEquals(items, other.items);
|
||||
|
||||
@override
|
||||
@@ -50,6 +53,7 @@ class BarTab {
|
||||
status,
|
||||
openedAt,
|
||||
closedAt,
|
||||
version,
|
||||
Object.hashAll(items),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
enum PaymentMethod {
|
||||
cash('cash', 'Cash', 'Cash payment'),
|
||||
payconiq('payconiq', 'Payconiq', 'Payconiq QR payment');
|
||||
payconiq('payconiq', 'Payconiq', 'Payconiq QR payment'),
|
||||
paymentPending('payment_pending', 'Payment pending', 'Unpaid remote close');
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
class RemoteCommand {
|
||||
final String id;
|
||||
final String type;
|
||||
final String tabId;
|
||||
final int expectedVersion;
|
||||
final String paymentMethod;
|
||||
final String requestedBy;
|
||||
final DateTime createdAt;
|
||||
|
||||
const RemoteCommand({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.tabId,
|
||||
required this.expectedVersion,
|
||||
required this.paymentMethod,
|
||||
required this.requestedBy,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
|
||||
return RemoteCommand(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
tabId: json['tabId'] as String,
|
||||
expectedVersion: json['expectedVersion'] as int,
|
||||
paymentMethod: json['paymentMethod'] as String? ?? 'cash',
|
||||
requestedBy: json['requestedBy'] as String? ?? '',
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isExpired {
|
||||
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
|
||||
return createdAt.isBefore(cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncResponse {
|
||||
final List<RemoteCommand> commands;
|
||||
final String? cursor;
|
||||
final List<String> plannedTabNames;
|
||||
|
||||
SyncResponse({
|
||||
required this.commands,
|
||||
this.cursor,
|
||||
this.plannedTabNames = const [],
|
||||
});
|
||||
|
||||
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||
final commandsList = (json['commands'] as List<dynamic>?)
|
||||
?.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
return SyncResponse(
|
||||
commands: commandsList,
|
||||
cursor: json['cursor'] as String?,
|
||||
plannedTabNames: (json['plannedTabNames'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SnapshotTab {
|
||||
final String id;
|
||||
final String customerName;
|
||||
final int version;
|
||||
final int totalInCents;
|
||||
final int itemCount;
|
||||
|
||||
const SnapshotTab({
|
||||
required this.id,
|
||||
required this.customerName,
|
||||
required this.version,
|
||||
required this.totalInCents,
|
||||
required this.itemCount,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'customerName': customerName,
|
||||
'version': version,
|
||||
'totalInCents': totalInCents,
|
||||
'itemCount': itemCount,
|
||||
};
|
||||
}
|
||||
|
||||
class SyncRequest {
|
||||
final String deviceId;
|
||||
final String? cursor;
|
||||
final List<SnapshotTab> openTabs;
|
||||
|
||||
const SyncRequest({
|
||||
required this.deviceId,
|
||||
this.cursor,
|
||||
required this.openTabs,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'deviceId': deviceId,
|
||||
if (cursor != null) 'cursor': cursor,
|
||||
'openTabs': openTabs.map((t) => t.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class CommandAck {
|
||||
final String commandId;
|
||||
final String status;
|
||||
final String? result;
|
||||
|
||||
const CommandAck({
|
||||
required this.commandId,
|
||||
required this.status,
|
||||
this.result,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'commandId': commandId,
|
||||
'status': status,
|
||||
if (result != null) 'result': result,
|
||||
};
|
||||
}
|
||||
|
||||
enum RemoteSyncEventType {
|
||||
tabClosedRemotely,
|
||||
syncCompleted,
|
||||
syncFailed,
|
||||
conflict,
|
||||
}
|
||||
|
||||
class RemoteSyncEvent {
|
||||
final RemoteSyncEventType type;
|
||||
final String? tabCustomerName;
|
||||
final String? requestedBy;
|
||||
final String? message;
|
||||
|
||||
const RemoteSyncEvent({
|
||||
required this.type,
|
||||
this.tabCustomerName,
|
||||
this.requestedBy,
|
||||
this.message,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class Venue {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
const Venue({required this.id, required this.name});
|
||||
|
||||
factory Venue.fromJson(Map<String, dynamic> json) => Venue(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ class DriftBarTabService implements BarTabService {
|
||||
status: row.status,
|
||||
openedAt: row.openedAt,
|
||||
closedAt: row.closedAt,
|
||||
version: row.version,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
@@ -292,6 +293,7 @@ class DriftBarTabService implements BarTabService {
|
||||
@override
|
||||
Future<BarTab> createTab({required String customerName}) async {
|
||||
final id = _uuid.v4();
|
||||
final now = DateTime.now();
|
||||
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
@@ -300,7 +302,8 @@ class DriftBarTabService implements BarTabService {
|
||||
id: id,
|
||||
customerName: customerName,
|
||||
status: const Value('open'),
|
||||
openedAt: DateTime.now(),
|
||||
openedAt: now,
|
||||
version: const Value(1),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -335,6 +338,8 @@ class DriftBarTabService implements BarTabService {
|
||||
if (updatedRows != 1) {
|
||||
throw StateError('Tab not found.');
|
||||
}
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -368,6 +373,14 @@ class DriftBarTabService implements BarTabService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementTabVersion(String tabId) async {
|
||||
await database.customUpdate(
|
||||
'UPDATE bar_tabs SET version = version + 1 WHERE id = ?',
|
||||
variables: [Variable.withString(tabId)],
|
||||
updates: {database.barTabs},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
@@ -414,6 +427,8 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
await _recordPurchase(tabItemId: tabItemId);
|
||||
});
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -421,7 +436,9 @@ class DriftBarTabService implements BarTabService {
|
||||
required String tabItemId,
|
||||
required int delta,
|
||||
}) async {
|
||||
return database.transaction(() async {
|
||||
String? tabId;
|
||||
|
||||
final actualDelta = await database.transaction(() async {
|
||||
final item = await (database.select(
|
||||
database.tabItems,
|
||||
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
||||
@@ -430,6 +447,8 @@ class DriftBarTabService implements BarTabService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tabId = item.tabId;
|
||||
|
||||
final requestedQuantity = item.quantity + delta;
|
||||
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
||||
|
||||
@@ -483,6 +502,12 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
return actualDelta;
|
||||
});
|
||||
|
||||
if (tabId != null && actualDelta != 0) {
|
||||
await _incrementTabVersion(tabId!);
|
||||
}
|
||||
|
||||
return actualDelta;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -544,6 +569,8 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
await deleteQuery.go();
|
||||
});
|
||||
|
||||
await _incrementTabVersion(tabId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../models/payment_method.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../utils/app_config.dart';
|
||||
import 'bar_tab_service.dart';
|
||||
|
||||
class RemoteSyncService {
|
||||
final AppDatabase database;
|
||||
final BarTabService barTabService;
|
||||
final FlutterSecureStorage secureStorage;
|
||||
|
||||
RemoteSyncService({
|
||||
required this.database,
|
||||
required this.barTabService,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
static const _deviceIdKey = 'remote_device_id';
|
||||
static const _authTokenKey = 'remote_auth_token';
|
||||
static const _serverUrlKey = 'remote_server_url';
|
||||
static const _venueIdKey = 'remote_venue_id';
|
||||
|
||||
final _eventController = StreamController<RemoteSyncEvent>.broadcast();
|
||||
|
||||
Stream<RemoteSyncEvent> get events => _eventController.stream;
|
||||
|
||||
bool _isSyncing = false;
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
Timer? _syncTimer;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _paused = false;
|
||||
|
||||
Future<String?> get deviceId async =>
|
||||
await secureStorage.read(key: _deviceIdKey);
|
||||
|
||||
Future<String?> get authToken async =>
|
||||
await secureStorage.read(key: _authTokenKey);
|
||||
|
||||
Future<String?> get venueId async =>
|
||||
await secureStorage.read(key: _venueIdKey);
|
||||
|
||||
Future<bool> get isConfigured async {
|
||||
final token = await authToken;
|
||||
final vid = await venueId;
|
||||
return token != null && token.isNotEmpty && vid != null && vid.isNotEmpty;
|
||||
}
|
||||
|
||||
String get _baseUrl => kRemoteServerUrl;
|
||||
|
||||
void startPeriodicSync({int intervalSeconds = 90}) {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(
|
||||
Duration(seconds: intervalSeconds),
|
||||
(_) => _onTick(),
|
||||
);
|
||||
}
|
||||
|
||||
void stopPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = null;
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_paused = true;
|
||||
}
|
||||
|
||||
void resume() {
|
||||
_paused = false;
|
||||
}
|
||||
|
||||
Future<void> _onTick() async {
|
||||
if (_isSyncing || _paused) return;
|
||||
await sync();
|
||||
}
|
||||
|
||||
Future<void> sync() async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
final configured = await isConfigured;
|
||||
if (!configured) return;
|
||||
|
||||
_isSyncing = true;
|
||||
|
||||
try {
|
||||
await _doSync();
|
||||
_consecutiveFailures = 0;
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncCompleted,
|
||||
));
|
||||
} catch (e, stack) {
|
||||
_consecutiveFailures++;
|
||||
debugPrint('RemoteSyncService: sync failed: $e');
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
_eventController.add(RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.syncFailed,
|
||||
message: e.toString(),
|
||||
));
|
||||
_applyBackoff();
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doSync() async {
|
||||
final token = await authToken;
|
||||
if (token == null) return;
|
||||
|
||||
final deviceId = await this.deviceId;
|
||||
if (deviceId == null) return;
|
||||
|
||||
final state = await _getSyncState();
|
||||
final openTabs = await _buildSnapshot();
|
||||
|
||||
final request = SyncRequest(
|
||||
deviceId: deviceId,
|
||||
cursor: state?.serverCursor,
|
||||
openTabs: openTabs,
|
||||
);
|
||||
|
||||
final response = await _httpPost(
|
||||
'$_baseUrl/devices/$deviceId/sync',
|
||||
body: request.toJson(),
|
||||
token: token,
|
||||
);
|
||||
|
||||
if (response == null) return;
|
||||
|
||||
final syncResponse = SyncResponse.fromJson(response);
|
||||
|
||||
for (final command in syncResponse.commands) {
|
||||
await _processCommand(command);
|
||||
}
|
||||
|
||||
for (final name in syncResponse.plannedTabNames) {
|
||||
final hasOpenTab = openTabs.any((t) => t.customerName == name);
|
||||
if (!hasOpenTab) {
|
||||
await barTabService.createTab(customerName: name);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncResponse.cursor != null) {
|
||||
await _saveCursor(syncResponse.cursor!);
|
||||
}
|
||||
|
||||
await _updateSyncState(success: true);
|
||||
}
|
||||
|
||||
Future<List<SnapshotTab>> _buildSnapshot() async {
|
||||
final openTabs = await barTabService.getOpenTabs();
|
||||
return openTabs.map((tab) => SnapshotTab(
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
Future<void> _processCommand(RemoteCommand command) async {
|
||||
final alreadyProcessed = await _isCommandProcessed(command.id);
|
||||
if (alreadyProcessed) return;
|
||||
|
||||
if (command.isExpired) {
|
||||
await _ackCommand(command.id, 'expired', 'Command expired');
|
||||
await _recordProcessed(command.id, command.type, 'expired');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type != 'close_tab') {
|
||||
await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}');
|
||||
await _recordProcessed(command.id, command.type, 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final tab = await barTabService.getTabById(command.tabId);
|
||||
|
||||
if (tab == null) {
|
||||
await _ackCommand(command.id, 'conflict', 'Tab not found');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab not found',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tab.isOpen) {
|
||||
await _ackCommand(command.id, 'conflict', 'Tab already closed');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab already closed',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab.version != command.expectedVersion) {
|
||||
await _ackCommand(command.id, 'conflict', 'Version mismatch');
|
||||
await _recordProcessed(command.id, command.type, 'conflict');
|
||||
_eventController.add(const RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.conflict,
|
||||
message: 'Tab was modified since the request',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
final method = PaymentMethod.fromValue(command.paymentMethod);
|
||||
await barTabService.closeTab(command.tabId, paymentMethod: method);
|
||||
|
||||
await _ackCommand(command.id, 'completed');
|
||||
await _recordProcessed(command.id, command.type, 'completed');
|
||||
|
||||
_eventController.add(RemoteSyncEvent(
|
||||
type: RemoteSyncEventType.tabClosedRemotely,
|
||||
tabCustomerName: tab.customerName,
|
||||
requestedBy: command.requestedBy,
|
||||
));
|
||||
} catch (e, stack) {
|
||||
debugPrint('RemoteSyncService: command processing failed: $e');
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
await _ackCommand(command.id, 'failed', e.toString());
|
||||
await _recordProcessed(command.id, command.type, 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _httpPost(
|
||||
String url, {
|
||||
required Map<String, dynamic> body,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
debugPrint('RemoteSyncService: token revoked');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = response.headers['retry-after'];
|
||||
if (retryAfter != null) {
|
||||
final seconds = int.tryParse(retryAfter) ?? 60;
|
||||
_applyBackoff(forcedDelay: seconds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
debugPrint('RemoteSyncService: HTTP ${response.statusCode}: ${response.body}');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.body.isEmpty) return {};
|
||||
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> _ackCommand(
|
||||
String commandId,
|
||||
String status, [
|
||||
String? result,
|
||||
]) async {
|
||||
final token = await authToken;
|
||||
final deviceId = await this.deviceId;
|
||||
if (token == null || deviceId == null) return;
|
||||
|
||||
try {
|
||||
final ack = CommandAck(commandId: commandId, status: status, result: result);
|
||||
await _httpPost(
|
||||
'$_baseUrl/devices/$deviceId/commands/$commandId/ack',
|
||||
body: ack.toJson(),
|
||||
token: token,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('RemoteSyncService: ack failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<RemoteSyncStateRow?> _getSyncState() async {
|
||||
final query = database.select(database.remoteSyncState)
|
||||
..where((row) => row.deviceId.equals('default'));
|
||||
return query.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> _saveCursor(String cursor) async {
|
||||
await database
|
||||
.into(database.remoteSyncState)
|
||||
.insertOnConflictUpdate(
|
||||
RemoteSyncStateCompanion(
|
||||
deviceId: const Value('default'),
|
||||
serverCursor: Value(cursor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateSyncState({required bool success}) async {
|
||||
final now = DateTime.now();
|
||||
final existing = await _getSyncState();
|
||||
|
||||
if (existing == null) {
|
||||
await database.into(database.remoteSyncState).insert(
|
||||
RemoteSyncStateCompanion.insert(
|
||||
deviceId: 'default',
|
||||
lastSuccessfulSyncAt: Value(success ? now : null),
|
||||
lastAttemptedSyncAt: Value(now),
|
||||
consecutiveFailureCount: Value(success ? 0 : 1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await (database.update(database.remoteSyncState)
|
||||
..where((row) => row.deviceId.equals('default')))
|
||||
.write(RemoteSyncStateCompanion(
|
||||
lastSuccessfulSyncAt: Value(success ? now : existing.lastSuccessfulSyncAt),
|
||||
lastAttemptedSyncAt: Value(now),
|
||||
consecutiveFailureCount: Value(
|
||||
success ? 0 : existing.consecutiveFailureCount + 1,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isCommandProcessed(String commandId) async {
|
||||
final query = database.select(database.processedRemoteCommands)
|
||||
..where((row) => row.commandId.equals(commandId));
|
||||
final result = await query.getSingleOrNull();
|
||||
return result != null;
|
||||
}
|
||||
|
||||
Future<void> _recordProcessed(
|
||||
String commandId,
|
||||
String commandType,
|
||||
String result,
|
||||
) async {
|
||||
await database
|
||||
.into(database.processedRemoteCommands)
|
||||
.insertOnConflictUpdate(
|
||||
ProcessedRemoteCommandsCompanion.insert(
|
||||
commandId: commandId,
|
||||
commandType: commandType,
|
||||
processedAt: DateTime.now(),
|
||||
result: result,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyBackoff({int? forcedDelay}) {
|
||||
final delay = forcedDelay ?? _calculateBackoff();
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer(Duration(seconds: delay), () => _onTick());
|
||||
}
|
||||
|
||||
int _calculateBackoff() {
|
||||
if (_consecutiveFailures <= 1) return 90;
|
||||
final backoff = 90 * (1 << (_consecutiveFailures - 1).clamp(0, 3));
|
||||
return backoff.clamp(90, 600);
|
||||
}
|
||||
|
||||
Future<void> setCredentials({
|
||||
required String deviceId,
|
||||
required String token,
|
||||
required String venueId,
|
||||
String? serverUrl,
|
||||
}) async {
|
||||
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
||||
await secureStorage.write(key: _authTokenKey, value: token);
|
||||
await secureStorage.write(key: _venueIdKey, value: venueId);
|
||||
if (serverUrl != null) {
|
||||
await secureStorage.write(key: _serverUrlKey, value: serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await secureStorage.delete(key: _deviceIdKey);
|
||||
await secureStorage.delete(key: _authTokenKey);
|
||||
await secureStorage.delete(key: _serverUrlKey);
|
||||
await secureStorage.delete(key: _venueIdKey);
|
||||
}
|
||||
|
||||
Future<DateTime?> get lastSuccessfulSyncAt async {
|
||||
final state = await _getSyncState();
|
||||
return state?.lastSuccessfulSyncAt;
|
||||
}
|
||||
|
||||
Future<int> get lastFailureCount async {
|
||||
final state = await _getSyncState();
|
||||
return state?.consecutiveFailureCount ?? 0;
|
||||
}
|
||||
|
||||
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() async {
|
||||
final query = database.select(database.processedRemoteCommands)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
|
||||
..limit(50);
|
||||
return query.get();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_syncTimer?.cancel();
|
||||
_eventController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
const kUpdateServerUrl = 'https://updater.brammie15.dev';
|
||||
const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup';
|
||||
const kRemoteServerUrl = 'https://api.kooltap.com/api/v1';
|
||||
const kSyncPollingIntervalSeconds = 90;
|
||||
const kSyncMaxBackoffSeconds = 600;
|
||||
const kCommandExpiryHours = 24;
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
@@ -5,15 +7,29 @@ import '../models/payment_method.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/tab_item.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../services/bar_tab_service.dart';
|
||||
import '../services/product_service.dart';
|
||||
import 'inventory_view_model.dart';
|
||||
import 'remote_sync_view_model.dart';
|
||||
|
||||
class BarScreenViewModel extends ChangeNotifier {
|
||||
final BarTabService barTabService;
|
||||
final InventoryViewModel inventory;
|
||||
final RemoteSyncViewModel remoteSyncViewModel;
|
||||
|
||||
BarScreenViewModel({required this.barTabService, required this.inventory});
|
||||
BarScreenViewModel({
|
||||
required this.barTabService,
|
||||
required this.inventory,
|
||||
required this.remoteSyncViewModel,
|
||||
}) {
|
||||
_eventSubscription = remoteSyncViewModel.syncService.events.listen(_onRemoteEvent);
|
||||
}
|
||||
|
||||
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
|
||||
|
||||
RemoteSyncEvent? _lastRemoteEvent;
|
||||
RemoteSyncEvent? get lastRemoteEvent => _lastRemoteEvent;
|
||||
|
||||
List<BarTab> _tabs = [];
|
||||
String? _selectedTabId;
|
||||
@@ -42,6 +58,24 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoteEvent(RemoteSyncEvent event) {
|
||||
_lastRemoteEvent = event;
|
||||
notifyListeners();
|
||||
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
|
||||
_reloadTabs();
|
||||
}
|
||||
}
|
||||
|
||||
void clearRemoteEvent() {
|
||||
_lastRemoteEvent = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_hasLoaded || _isLoading) return;
|
||||
|
||||
@@ -72,6 +106,11 @@ class BarScreenViewModel extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAndSync() async {
|
||||
await load();
|
||||
await remoteSyncViewModel.syncNow();
|
||||
}
|
||||
|
||||
void selectTab(String tabId) {
|
||||
_selectedTabId = tabId;
|
||||
notifyListeners();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/venue.dart';
|
||||
import '../services/remote_sync_service.dart';
|
||||
import '../utils/app_config.dart';
|
||||
|
||||
enum RegistrationStep { initial, loadingVenues, venuesLoaded, error, registering, registered }
|
||||
|
||||
class RegistrationViewModel extends ChangeNotifier {
|
||||
final RemoteSyncService syncService;
|
||||
|
||||
RegistrationViewModel({required this.syncService});
|
||||
|
||||
RegistrationStep _step = RegistrationStep.initial;
|
||||
RegistrationStep get step => _step;
|
||||
|
||||
List<Venue> _venues = [];
|
||||
List<Venue> get venues => _venues;
|
||||
|
||||
Venue? _selectedVenue;
|
||||
Venue? get selectedVenue => _selectedVenue;
|
||||
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
void selectVenue(Venue venue) {
|
||||
_selectedVenue = venue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> fetchVenues() async {
|
||||
_step = RegistrationStep.loadingVenues;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$kRemoteServerUrl/venues/'))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
|
||||
_venues = data
|
||||
.map((e) => Venue.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
_step = RegistrationStep.venuesLoaded;
|
||||
} else {
|
||||
_errorMessage = 'Failed to load venues (HTTP ${response.statusCode})';
|
||||
_step = RegistrationStep.error;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('RegistrationViewModel: fetchVenues error: $e');
|
||||
_errorMessage = e.toString();
|
||||
_step = RegistrationStep.error;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> registerDevice({
|
||||
required String deviceName,
|
||||
required String registrationCode,
|
||||
}) async {
|
||||
final venue = _selectedVenue;
|
||||
if (venue == null) return false;
|
||||
|
||||
_step = RegistrationStep.registering;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$kRemoteServerUrl/devices/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'venueId': venue.id,
|
||||
'name': deviceName,
|
||||
'registrationCode': registrationCode,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final deviceId = data['deviceId'] as String;
|
||||
final token = data['token'] as String;
|
||||
|
||||
await syncService.setCredentials(
|
||||
deviceId: deviceId,
|
||||
token: token,
|
||||
venueId: venue.id,
|
||||
serverUrl: kRemoteServerUrl,
|
||||
);
|
||||
|
||||
_step = RegistrationStep.registered;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
String message;
|
||||
try {
|
||||
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
message = errorData['message'] as String? ?? 'Registration failed';
|
||||
} catch (_) {
|
||||
message = 'Registration failed (HTTP ${response.statusCode})';
|
||||
}
|
||||
_errorMessage = message;
|
||||
_step = RegistrationStep.venuesLoaded;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('RegistrationViewModel: registerDevice error: $e');
|
||||
_errorMessage = e.toString();
|
||||
_step = RegistrationStep.venuesLoaded;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> retry() async {
|
||||
await fetchVenues();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_step = RegistrationStep.initial;
|
||||
_venues = [];
|
||||
_selectedVenue = null;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../services/remote_sync_service.dart';
|
||||
import '../utils/app_config.dart';
|
||||
|
||||
class RemoteSyncViewModel extends ChangeNotifier {
|
||||
final RemoteSyncService syncService;
|
||||
|
||||
RemoteSyncViewModel({required this.syncService}) {
|
||||
_eventSubscription = syncService.events.listen(_onEvent);
|
||||
}
|
||||
|
||||
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
|
||||
|
||||
List<RemoteSyncEvent> _recentEvents = [];
|
||||
|
||||
List<RemoteSyncEvent> get recentEvents => _recentEvents;
|
||||
|
||||
bool _isSyncing = false;
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
bool _isConfigured = false;
|
||||
bool get isConfigured => _isConfigured;
|
||||
|
||||
DateTime? _lastSyncAt;
|
||||
DateTime? get lastSyncAt => _lastSyncAt;
|
||||
|
||||
String? _lastError;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
String? _venueId;
|
||||
String? get venueId => _venueId;
|
||||
|
||||
List<ProcessedRemoteCommandRow> _activity = [];
|
||||
List<ProcessedRemoteCommandRow> get activity => _activity;
|
||||
|
||||
bool _isRegistering = false;
|
||||
bool get isRegistering => _isRegistering;
|
||||
|
||||
String? _registrationError;
|
||||
String? get registrationError => _registrationError;
|
||||
|
||||
Future<void> init() async {
|
||||
_isConfigured = await syncService.isConfigured;
|
||||
_venueId = await syncService.venueId;
|
||||
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
|
||||
await loadActivity();
|
||||
if (_isConfigured) {
|
||||
syncService.startPeriodicSync();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshStatus() async {
|
||||
_isConfigured = await syncService.isConfigured;
|
||||
_venueId = await syncService.venueId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> syncNow() async {
|
||||
_isSyncing = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
await syncService.sync();
|
||||
|
||||
_isSyncing = syncService.isSyncing;
|
||||
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
|
||||
await loadActivity();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadActivity() async {
|
||||
try {
|
||||
_activity = await syncService.getRecentActivity();
|
||||
} catch (e) {
|
||||
debugPrint('RemoteSyncViewModel: loadActivity error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> registerDevice({
|
||||
required String venueId,
|
||||
required String deviceName,
|
||||
required String registrationCode,
|
||||
}) async {
|
||||
_isRegistering = true;
|
||||
_registrationError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$kRemoteServerUrl/devices/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'venueId': venueId,
|
||||
'name': deviceName,
|
||||
'registrationCode': registrationCode,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final deviceId = data['deviceId'] as String;
|
||||
final token = data['token'] as String;
|
||||
|
||||
await syncService.setCredentials(
|
||||
deviceId: deviceId,
|
||||
token: token,
|
||||
venueId: venueId,
|
||||
serverUrl: kRemoteServerUrl,
|
||||
);
|
||||
|
||||
_isConfigured = true;
|
||||
_venueId = venueId;
|
||||
_isRegistering = false;
|
||||
notifyListeners();
|
||||
|
||||
syncService.startPeriodicSync();
|
||||
await syncNow();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
final body = response.body;
|
||||
String message;
|
||||
try {
|
||||
final errorData = jsonDecode(body) as Map<String, dynamic>;
|
||||
message = errorData['message'] as String? ?? 'Registration failed';
|
||||
} catch (_) {
|
||||
message = 'Registration failed (HTTP ${response.statusCode})';
|
||||
}
|
||||
_registrationError = message;
|
||||
_isRegistering = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('RemoteSyncViewModel: registration error: $e');
|
||||
_registrationError = e.toString();
|
||||
_isRegistering = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlink() async {
|
||||
syncService.stopPeriodicSync();
|
||||
await syncService.clearCredentials();
|
||||
_isConfigured = false;
|
||||
_venueId = null;
|
||||
_lastSyncAt = null;
|
||||
_lastError = null;
|
||||
_recentEvents = [];
|
||||
_activity = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startSync() {
|
||||
syncService.startPeriodicSync();
|
||||
syncNow();
|
||||
}
|
||||
|
||||
void _onEvent(RemoteSyncEvent event) {
|
||||
_recentEvents = [event, ..._recentEvents.take(49)];
|
||||
_lastSyncAt = DateTime.now();
|
||||
|
||||
if (event.type == RemoteSyncEventType.syncFailed) {
|
||||
_lastError = event.message;
|
||||
} else if (event.type == RemoteSyncEventType.syncCompleted) {
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
syncService.pause();
|
||||
}
|
||||
|
||||
void resume() {
|
||||
syncService.resume();
|
||||
syncNow();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
syncService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,27 +3,36 @@ import 'package:kooltab2/models/product.dart';
|
||||
import 'package:kooltab2/models/tab_item.dart';
|
||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||
import 'package:kooltab2/services/product_service.dart';
|
||||
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockBarTabService extends Mock implements BarTabService {}
|
||||
|
||||
class MockProductService extends Mock implements ProductService {}
|
||||
|
||||
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
|
||||
|
||||
void main() {
|
||||
late MockBarTabService barTabService;
|
||||
late MockProductService productService;
|
||||
late InventoryViewModel inventory;
|
||||
late BarScreenViewModel viewModel;
|
||||
late RemoteSyncViewModel remoteSyncViewModel;
|
||||
|
||||
setUp(() {
|
||||
barTabService = MockBarTabService();
|
||||
productService = MockProductService();
|
||||
inventory = InventoryViewModel(productService: productService);
|
||||
remoteSyncViewModel = RemoteSyncViewModel(
|
||||
syncService: MockRemoteSyncService(),
|
||||
);
|
||||
viewModel = BarScreenViewModel(
|
||||
barTabService: barTabService,
|
||||
inventory: inventory,
|
||||
remoteSyncViewModel: remoteSyncViewModel,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import 'package:kooltab2/l10n/app_localizations.dart';
|
||||
import 'package:kooltab2/models/bar_tab.dart';
|
||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||
import 'package:kooltab2/services/product_service.dart';
|
||||
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
|
||||
import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -14,15 +16,21 @@ class MockBarTabService extends Mock implements BarTabService {}
|
||||
|
||||
class MockProductService extends Mock implements ProductService {}
|
||||
|
||||
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
|
||||
|
||||
void main() {
|
||||
testWidgets('edit dialog disposes its controller after closing', (
|
||||
tester,
|
||||
) async {
|
||||
final barTabService = MockBarTabService();
|
||||
final productService = MockProductService();
|
||||
final remoteSyncViewModel = RemoteSyncViewModel(
|
||||
syncService: MockRemoteSyncService(),
|
||||
);
|
||||
final viewModel = BarScreenViewModel(
|
||||
barTabService: barTabService,
|
||||
inventory: InventoryViewModel(productService: productService),
|
||||
remoteSyncViewModel: remoteSyncViewModel,
|
||||
);
|
||||
final tab = BarTab(
|
||||
id: 'tab-1',
|
||||
|
||||
Reference in New Issue
Block a user