Drain bills to the back office automatically, over MQTT or HTTP

Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.

Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
  request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
  several passes reading the same pending rows and send every bill twice.
  A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
  matters: a store's terminals all fail at the same instant when the line
  drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
  refused batch). Pressing Sync clears the halt.

Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
  repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
  topic, retained Last Will for terminal-offline detection, downlink for
  catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
  the bytes, not that the ledger took the sale. Only ids the back office names
  are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.

Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
  office later loses can be re-sent in full. Purged after that; archived
  totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
  the orders table and day_archive, and summing both would overstate the day.

Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
  evaluates the receiver before the awaited argument, so a connectivity drop
  during the wait was silently overwritten by the stale snapshot. Caught by
  the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
  check, throwing "used after dispose" when Settings was left mid-load. This
  was pre-existing and reached the cashier as a red screen.

Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
  SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
  the idempotency requirement that at-least-once delivery makes mandatory.

Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.

Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 10:57:29 +05:30
parent af3933092f
commit 0a49323858
29 changed files with 2855 additions and 112 deletions

View File

@@ -4,12 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/constants/app_constants.dart';
import '../core/router/app_router.dart';
import '../core/theme/app_theme.dart';
import '../presentation/sync/providers/sync_controller.dart';
class NearlePosApp extends ConsumerWidget {
const NearlePosApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watched, not awaited: the till opens immediately and the queue drains
// behind it. Nothing on screen depends on this having finished.
ref.watch(syncBootstrapProvider);
return MaterialApp.router(
title: AppConstants.appName,
debugShowCheckedModeBanner: false,

View File

@@ -1,13 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart';
import '../data/datasources/remote_catalogue_source.dart';
import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
@@ -44,20 +51,72 @@ final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
),
);
final remoteOrderSinkProvider = Provider<RemoteOrderSink>(
(ref) => RemoteOrderSink(
isOffline: () => ref.read(simulateOfflineProvider),
),
);
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
final syncConfigProvider = StateProvider<SyncConfig>((ref) => const SyncConfig());
/// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// The wire itself. Rebuilt when the configuration changes, and the old one is
/// closed so a re-pointed terminal does not keep a stale broker session open.
final orderTransportProvider = Provider<OrderTransport>((ref) {
final config = ref.watch(syncConfigProvider);
final transport = switch (config.transport) {
TransportKind.mqtt => MqttOrderTransport(config: config),
TransportKind.http => HttpOrderTransport(config: config),
TransportKind.simulated => SimulatedOrderTransport(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(transport.dispose);
return transport;
});
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider),
ref.watch(remoteOrderSinkProvider),
ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize,
),
);
/// Decides when bills are uploaded. Started once, by the app shell.
final syncEngineProvider = Provider<SyncEngine>((ref) {
final transport = ref.watch(orderTransportProvider);
final engine = SyncEngine(
repository: ref.watch(syncRepositoryProvider),
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
downlink: transport.downlink,
onCatalogueChanged: () async {
await ref.read(syncRepositoryProvider).importCatalogue();
ref.invalidate(localStoreProvider);
},
);
ref.onDispose(engine.dispose);
return engine;
});
/// Live engine state for the header pill and the events screen.
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
final engine = ref.watch(syncEngineProvider);
return engine.states.map((s) => s);
});
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(

View File

@@ -0,0 +1,123 @@
/// Which route completed bills take to the back office.
enum TransportKind {
/// No back office wired up — bills queue and drain against a local stub.
simulated,
/// Plain request/response upload. Easiest to debug: curl reproduces it and
/// failures come back as status codes.
http,
/// Persistent connection. Costs a broker, and buys the downlink: head office
/// can push a price change or ask a terminal to sync without waiting for it
/// to ask first.
mqtt,
}
/// Everything the terminal needs to reach the back office.
///
/// One object rather than scattered constants so a store can be re-pointed at
/// a different broker without a rebuild, and so tests can construct a whole
/// configuration inline.
class SyncConfig {
const SyncConfig({
this.transport = TransportKind.simulated,
this.storeId = 'store-01',
this.terminalId = 'TERM-01',
this.brokerHost = '',
this.brokerPort = 8883,
this.useTls = true,
this.username,
this.password,
this.httpBaseUrl = '',
this.apiKey,
this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50,
});
final TransportKind transport;
/// Namespaces every topic. Two stores on one broker must never collide.
final String storeId;
final String terminalId;
final String brokerHost;
final int brokerPort;
final bool useTls;
final String? username;
final String? password;
final String httpBaseUrl;
final String? apiKey;
/// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending.
///
/// Generous on purpose: a timeout that fires while the server is committing
/// produces a duplicate send, which is safe only because the back office
/// keys on order id — but it is still wasted traffic on a slow shop line.
final Duration ackTimeout;
/// Bills per publish. Small enough to stay well inside broker message limits
/// on a day that has built up a long backlog.
final int batchSize;
bool get isConfigured => switch (transport) {
TransportKind.simulated => true,
TransportKind.http => httpBaseUrl.isNotEmpty,
TransportKind.mqtt => brokerHost.isNotEmpty,
};
// ------------------------------------------------------------------ Topics
String get _base => 'pos/$storeId/$terminalId';
/// Uplink. Completed bills, QoS 1.
String get orderTopic => '$_base/order';
/// The back office's answer, naming the ids it committed. Subscribed at
/// QoS 1: losing an ack means re-sending bills that are already banked.
String get ackTopic => '$_base/ack';
/// Retained, and set as the will message. A terminal that loses power stops
/// refreshing it and the broker publishes `offline` on its behalf, which is
/// what makes a head-office "which tills are dark" board possible.
String get statusTopic => '$_base/status';
/// Store-wide downlink: catalogue changes land here for every terminal.
String get catalogueTopic => 'pos/$storeId/catalogue';
/// Addressed to this terminal alone.
String get commandTopic => '$_base/command';
/// Stable across restarts so the broker can resume a session and redeliver
/// anything in flight, rather than treating each launch as a new client.
String get clientId => 'pos-$storeId-$terminalId';
SyncConfig copyWith({
TransportKind? transport,
String? storeId,
String? terminalId,
String? brokerHost,
int? brokerPort,
bool? useTls,
String? username,
String? password,
String? httpBaseUrl,
String? apiKey,
Duration? ackTimeout,
int? batchSize,
}) =>
SyncConfig(
transport: transport ?? this.transport,
storeId: storeId ?? this.storeId,
terminalId: terminalId ?? this.terminalId,
brokerHost: brokerHost ?? this.brokerHost,
brokerPort: brokerPort ?? this.brokerPort,
useTls: useTls ?? this.useTls,
username: username ?? this.username,
password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey,
ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize,
);
}

View File

@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
/// Tells the terminal when it is worth trying the network.
///
/// This is a *hint*, not proof. The platform reports whether an interface is
/// up, which on shop wifi is routinely true while the line itself is dead. So
/// nothing here decides that a sync succeeded — only the transport's answer
/// does. What this buys is the moment to try: the difference between a bill
/// going up the second the router comes back and it waiting for the next poll.
///
/// The Settings "Simulate offline" switch is folded in here so there is one
/// answer to "are we online", rather than a real state and a demo state that
/// can disagree.
class ConnectivityService {
ConnectivityService({
Connectivity? connectivity,
bool Function()? isSimulatedOffline,
}) : _connectivity = connectivity ?? Connectivity(),
_isSimulatedOffline = isSimulatedOffline ?? (() => false);
final Connectivity _connectivity;
final bool Function() _isSimulatedOffline;
final _controller = StreamController<bool>.broadcast();
StreamSubscription<List<ConnectivityResult>>? _subscription;
bool _hasInterface = true;
bool _started = false;
/// Fires only when the answer changes, so a subscriber can treat every event
/// as an edge.
Stream<bool> get onlineChanges => _controller.stream;
bool get isOnline => _hasInterface && !_isSimulatedOffline();
Future<void> start() async {
if (_started) return;
_started = true;
try {
_hasInterface = _hasAny(await _connectivity.checkConnectivity());
} on Exception {
// No platform channel — a test host, or a desktop build without the
// plugin registered. Assume online and let the transport be the judge;
// refusing to try would be worse than trying and failing.
_hasInterface = true;
}
try {
_subscription = _connectivity.onConnectivityChanged.listen((results) {
_update(_hasAny(results));
});
} on Exception {
// As above: without the stream the periodic poll still drains the queue.
}
}
/// Re-evaluates after the Settings switch is flipped.
void refresh() => _update(_hasInterface);
void _update(bool hasInterface) {
final was = isOnline;
_hasInterface = hasInterface;
if (isOnline != was) _controller.add(isOnline);
}
static bool _hasAny(List<ConnectivityResult> results) =>
results.any((r) => r != ConnectivityResult.none);
Future<void> dispose() async {
await _subscription?.cancel();
await _controller.close();
}
}

View File

@@ -84,29 +84,3 @@ class RemoteCatalogueSource {
);
}
}
/// Stands in for the back-office order intake API.
class RemoteOrderSink {
RemoteOrderSink({required this.isOffline});
final bool Function() isOffline;
/// Uploads a batch of orders and returns the ids the server accepted.
///
/// Throws on transport failure so the caller leaves every row at
/// `sync_status = 0` rather than marking anything sent.
Future<List<String>> pushOrders(List<Map<String, Object?>> orders) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
);
if (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
);
}
return orders.map((o) => o['id']! as String).toList();
}
}

View File

@@ -11,9 +11,13 @@ import 'catalogue_dao.dart';
/// Persists bills.
///
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
/// upload selects those rows, sends them, and flips the accepted ones to 1.
/// Nothing is ever deleted as part of syncing.
/// Every completed sale lands here with `sync_status = 0`, which makes this
/// table the terminal's outbox: the drain engine selects those rows, sends
/// them, and flips the ones the back office confirmed to 1.
///
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
/// itself never deletes anything.
class OrderDao {
const OrderDao(this._db);
@@ -22,6 +26,9 @@ class OrderDao {
static const int pending = 0;
static const int synced = 1;
/// How long an accepted bill stays re-sendable on the terminal.
static const Duration retentionWindow = Duration(days: 7);
static String businessDateOf(DateTime dt) =>
'${dt.year.toString().padLeft(4, '0')}-'
'${dt.month.toString().padLeft(2, '0')}-'
@@ -132,20 +139,26 @@ class OrderDao {
Future<List<SaleTransaction>> recent({int limit = 100}) =>
_query(orderBy: 'created_at DESC', limit: limit);
/// Bills for a day, optionally narrowed to one operator.
/// Bills for a day that have *not* yet been accepted by the server.
///
/// A shift report that is settled against a till has to cover exactly the
/// bills that cashier rang, not everything the terminal did that day.
///
/// Restricted to pending rows on purpose. The moment a bill is accepted its
/// figures are folded into [Tables.dayArchive], and the report adds the two
/// together — so an accepted row still sitting here during its retention
/// window would be counted twice and inflate the day's takings.
Future<List<SaleTransaction>> forBusinessDate(
DateTime day, {
String? cashierName,
}) =>
_query(
where: cashierName == null
? 'business_date = ?'
: 'business_date = ? AND cashier_name = ?',
? 'business_date = ? AND sync_status = ?'
: 'business_date = ? AND sync_status = ? AND cashier_name = ?',
whereArgs: [
businessDateOf(day),
pending,
if (cashierName != null) cashierName,
],
);
@@ -241,14 +254,19 @@ class OrderDao {
);
// ----------------------------------------------------------------- Sync
/// Folds accepted orders into the day archive, then deletes them.
/// Folds accepted orders into the day archive and marks them synced.
///
/// Once the server holds a bill the terminal has no reason to keep it, so
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
/// shift totals a cashier sees do not collapse after a mid-shift sync.
/// Both steps run in one transaction: if the delete fails the archive is
/// rolled back with it, and nothing is counted twice.
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
/// Their figures are added to [Tables.dayArchive] so the shift totals a
/// cashier sees do not collapse after a mid-shift sync, and the rows
/// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore]
/// retires them. Keeping them buys a recovery window: if the back office
/// loses a batch, the full bills are still on the terminal and can be sent
/// again. Once deleted only the archived totals survive, and a lost bill's
/// line items are gone for good.
///
/// Both steps run in one transaction: if the status flip fails the archive
/// rolls back with it, and nothing is counted twice.
Future<void> archiveAccepted(List<SaleTransaction> orders) async {
if (orders.isEmpty) return;
await _db.transaction((txn) async {
@@ -331,17 +349,30 @@ class OrderDao {
);
}
// order_items goes with it via ON DELETE CASCADE.
final ids = orders.map((o) => o.id).toList();
final placeholders = List.filled(ids.length, '?').join(',');
await txn.delete(
Tables.orders,
where: 'id IN ($placeholders)',
whereArgs: ids,
await txn.rawUpdate(
'UPDATE ${Tables.orders} '
'SET sync_status = ?, synced_at = ?, sync_error = NULL '
'WHERE id IN ($placeholders)',
[synced, DateTime.now().millisecondsSinceEpoch, ...ids],
);
});
}
/// Retires bills the server took delivery of more than [retention] ago.
///
/// Their archived totals are untouched and stay forever — this drops only the
/// re-sendable copy once the recovery window has closed, so a terminal that
/// trades for years does not carry every bill it has ever rung.
///
/// Returns how many rows went. `order_items` follows via ON DELETE CASCADE.
Future<int> purgeSyncedBefore(DateTime cutoff) => _db.delete(
Tables.orders,
where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?',
whereArgs: [synced, cutoff.millisecondsSinceEpoch],
);
/// Archived figures for a business day, one row per cashier.
///
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a

View File

@@ -0,0 +1,154 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/config/sync_config.dart';
import 'order_transport.dart';
/// Ships bills over plain HTTP.
///
/// No downlink and no persistent connection — but a failure is a status code,
/// a request is reproducible with curl, and there is no broker to run. Worth
/// having as the route to bring up first, and as the fallback when a broker is
/// unreachable but the internet is not.
///
/// The endpoint must answer with the ids it committed:
///
/// ```json
/// { "accepted": ["<order-id>", ...], "rejected": { "<order-id>": "reason" } }
/// ```
///
/// A bare `200 OK` is not treated as success for any bill. Silence about which
/// rows landed is not the same as landing them, and guessing here would retire
/// a day's takings on an empty response.
class HttpOrderTransport implements OrderTransport {
HttpOrderTransport({required this.config, http.Client? client})
: _client = client ?? http.Client();
final SyncConfig config;
final http.Client _client;
final _connection = StreamController<bool>.broadcast();
bool _reachable = true;
@override
String get label => 'HTTP ${config.httpBaseUrl}';
@override
bool get isConnected => _reachable;
/// Nothing to receive: HTTP is a route out, not in.
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => _connection.stream;
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
if (config.httpBaseUrl.isEmpty) {
throw const TransportException(
'No back-office URL configured for this terminal.',
retryable: false,
);
}
final uri = Uri.parse('${config.httpBaseUrl}/orders');
http.Response response;
try {
response = await _client
.post(
uri,
headers: {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
// Lets the endpoint collapse a retried batch server-side rather
// than relying on every order id being checked individually.
'idempotency-key': _batchKey(orders),
},
body: jsonEncode({
'schema': 1,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
}),
)
.timeout(config.ackTimeout);
} on Exception catch (e) {
_setReachable(false);
throw TransportException('Upload failed: $e');
}
if (response.statusCode == 401 || response.statusCode == 403) {
_setReachable(false);
throw TransportException(
'The back office rejected this terminal\'s credentials '
'(${response.statusCode}). Bills are safe locally, but sync will keep '
'failing until the terminal is re-authorised.',
retryable: false,
);
}
if (response.statusCode >= 300) {
_setReachable(false);
throw TransportException(
'Back office returned ${response.statusCode}: '
'${_trim(response.body)}',
);
}
_setReachable(true);
Map<String, Object?> body;
try {
body = jsonDecode(response.body) as Map<String, Object?>;
} on Exception {
throw TransportException(
'Back office answered 200 with a body this terminal could not read, '
'so no bill was marked synced: ${_trim(response.body)}',
);
}
final accepted = (body['accepted'] as List<Object?>? ?? const [])
.whereType<String>()
.toList();
final rejected = <String, String>{};
final raw = body['rejected'];
if (raw is Map) {
raw.forEach((k, v) => rejected['$k'] = '$v');
}
return PushReceipt(accepted: accepted, rejected: rejected);
}
/// Stable for a given set of bills, so a retry after a timeout carries the
/// same key as the attempt that may already have landed.
String _batchKey(List<Map<String, Object?>> orders) =>
orders.map((o) => o['id']).join('|').hashCode.toRadixString(16);
void _setReachable(bool value) {
if (_reachable == value) return;
_reachable = value;
_connection.add(value);
}
static String _trim(String body) =>
body.length <= 200 ? body : '${body.substring(0, 200)}';
@override
Future<void> dispose() async {
_client.close();
await _connection.close();
}
}

View File

@@ -0,0 +1,332 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
import 'package:uuid/uuid.dart';
import '../../core/config/sync_config.dart';
import 'order_transport.dart';
/// Ships bills over MQTT and listens for what head office pushes back.
///
/// ### Why the broker's acknowledgement is not enough
///
/// QoS 1 gives a PUBACK from the *broker*, meaning "I hold these bytes". It
/// says nothing about whether the back office parsed the batch, or whether the
/// ledger accepted it. Marking bills synced on PUBACK would retire a day's
/// takings on the word of a message queue.
///
/// So every publish carries a `batch_id` and this waits for the back office to
/// answer on [SyncConfig.ackTopic] naming the ids it actually committed. No
/// answer means no sync, and the bills are sent again.
///
/// ### Duplicates are expected
///
/// QoS 1 is at-least-once, and a batch whose ack is lost will be re-sent in
/// full. The back office must key on `order.id` and upsert. Every id is a UUID
/// minted at the till, so this costs the server one unique index.
class MqttOrderTransport implements OrderTransport {
MqttOrderTransport({
required this.config,
MqttClient Function(SyncConfig)? clientFactory,
}) : _clientFactory = clientFactory ?? _defaultClient;
final SyncConfig config;
final MqttClient Function(SyncConfig) _clientFactory;
static const _uuid = Uuid();
MqttClient? _client;
/// Batches published but not yet answered, keyed by `batch_id`.
final _awaitingAck = <String, Completer<PushReceipt>>{};
final _downlink = StreamController<DownlinkMessage>.broadcast();
final _connection = StreamController<bool>.broadcast();
/// Guards against two drains racing to open the same connection.
Future<void>? _connecting;
StreamSubscription<List<MqttReceivedMessage<MqttMessage>>>? _updates;
static MqttClient _defaultClient(SyncConfig config) {
final client = MqttServerClient.withPort(
config.brokerHost,
config.clientId,
config.brokerPort,
);
client.secure = config.useTls;
// Well under the shortest NAT timeout a shop router is likely to impose;
// a connection that dies silently is worse than one that pings.
client.keepAlivePeriod = 20;
client.autoReconnect = true;
client.resubscribeOnAutoReconnect = true;
client.logging(on: false);
return client;
}
@override
String get label => 'MQTT ${config.brokerHost}:${config.brokerPort}';
@override
bool get isConnected =>
_client?.connectionStatus?.state == MqttConnectionState.connected;
@override
Stream<DownlinkMessage> get downlink => _downlink.stream;
@override
Stream<bool> get connectionState => _connection.stream;
// ------------------------------------------------------------- Connection
@override
Future<void> connect() {
if (isConnected) return Future.value();
return _connecting ??= _doConnect().whenComplete(() => _connecting = null);
}
Future<void> _doConnect() async {
if (config.brokerHost.isEmpty) {
throw const TransportException(
'No MQTT broker configured for this terminal.',
retryable: false,
);
}
final client = _client ??= _clientFactory(config);
client.onDisconnected = () {
_connection.add(false);
// Nobody is coming to answer these. Failing them now returns the bills
// to pending immediately instead of holding the drain for the full ack
// timeout on a connection that is already gone.
_failAllAwaiting('Connection to the broker dropped.');
};
client.onConnected = () => _connection.add(true);
// Retained, so head office sees this terminal's last known state even if
// its dashboard connects hours later. The broker publishes it on our
// behalf if the till loses power mid-shift — which is the only way to tell
// "closed for the night" from "unplugged".
client.connectionMessage = MqttConnectMessage()
.withClientIdentifier(config.clientId)
.withWillTopic(config.statusTopic)
.withWillMessage(jsonEncode({'state': 'offline'}))
.withWillQos(MqttQos.atLeastOnce)
.withWillRetain();
try {
await client.connect(config.username, config.password);
} on Exception catch (e) {
client.disconnect();
throw TransportException('Could not reach the broker: $e');
}
if (client.connectionStatus?.state != MqttConnectionState.connected) {
final status = client.connectionStatus;
client.disconnect();
throw TransportException(
'Broker refused the connection: '
'${status?.returnCode?.name ?? 'unknown'}',
// Bad credentials or an unauthorised client id will be refused just as
// firmly on the next attempt; backing off forever would only hide it.
retryable: status?.returnCode != MqttConnectReturnCode.notAuthorized,
);
}
client
..subscribe(config.ackTopic, MqttQos.atLeastOnce)
..subscribe(config.catalogueTopic, MqttQos.atLeastOnce)
..subscribe(config.commandTopic, MqttQos.atLeastOnce);
await _updates?.cancel();
_updates = client.updates?.listen(_onUpdates);
_publish(
config.statusTopic,
jsonEncode({
'state': 'online',
'terminal_id': config.terminalId,
'at': DateTime.now().toIso8601String(),
}),
retain: true,
);
_connection.add(true);
}
void _onUpdates(List<MqttReceivedMessage<MqttMessage>> events) {
for (final event in events) {
final message = event.payload;
if (message is! MqttPublishMessage) continue;
handleInbound(
event.topic,
MqttPublishPayload.bytesToStringAsString(message.payload.message),
);
}
}
// ----------------------------------------------------------------- Uplink
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
await connect();
final batchId = _uuid.v4();
final completer = Completer<PushReceipt>();
_awaitingAck[batchId] = completer;
try {
_publish(
config.orderTopic,
jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
}),
);
return await completer.future.timeout(
config.ackTimeout,
onTimeout: () => throw TransportException(
'The back office did not confirm the batch within '
'${config.ackTimeout.inSeconds}s. The bills are still on this '
'terminal and will be sent again.',
),
);
} finally {
_awaitingAck.remove(batchId);
}
}
void _publish(String topic, String payload, {bool retain = false}) {
final builder = MqttClientPayloadBuilder()..addString(payload);
try {
_client!.publishMessage(
topic,
MqttQos.atLeastOnce,
builder.payload!,
retain: retain,
);
} on Exception catch (e) {
throw TransportException('Publish to $topic failed: $e');
}
}
// --------------------------------------------------------------- Downlink
/// Registers a batch as awaiting its ack, without publishing one.
///
/// Lets a test drive the correlation rules — which is where the logic that
/// decides whether a bill counts as banked actually lives — without standing
/// up a broker.
@visibleForTesting
Future<PushReceipt> awaitAck(String batchId) {
final completer = Completer<PushReceipt>();
_awaitingAck[batchId] = completer;
return completer.future;
}
/// Routes one inbound message. Separated from the client so the correlation
/// and parsing rules can be tested without a broker.
@visibleForTesting
void handleInbound(String topic, String payload) {
Map<String, Object?> body;
try {
body = jsonDecode(payload) as Map<String, Object?>;
} on FormatException {
// A malformed message must not take down the connection: the next one
// may be a perfectly good ack releasing a day's bills.
debugPrint('Discarded unparseable MQTT message on $topic');
return;
}
if (topic == config.ackTopic) {
_resolveAck(body);
return;
}
if (topic == config.catalogueTopic) {
_downlink.add(
DownlinkMessage(kind: DownlinkKind.catalogueChanged, payload: body),
);
return;
}
if (topic == config.commandTopic) {
final command = body['command'] as String?;
_downlink.add(
DownlinkMessage(
kind: command == 'sync'
? DownlinkKind.syncRequested
: DownlinkKind.unknown,
payload: body,
),
);
}
}
void _resolveAck(Map<String, Object?> body) {
final batchId = body['batch_id'] as String?;
if (batchId == null) return;
// An ack for a batch we are no longer waiting on — we timed out, or the
// terminal restarted. Harmless: those bills are still pending and will go
// up again, and the back office keys on order id.
final completer = _awaitingAck.remove(batchId);
if (completer == null || completer.isCompleted) return;
final accepted = (body['accepted'] as List<Object?>? ?? const [])
.whereType<String>()
.toList();
final rejected = <String, String>{};
final raw = body['rejected'];
if (raw is Map) {
raw.forEach((k, v) => rejected['$k'] = '$v');
} else if (raw is List) {
// Tolerates a back office that sends bare ids with no reason.
for (final id in raw.whereType<String>()) {
rejected[id] = 'Rejected by the back office';
}
}
completer.complete(PushReceipt(accepted: accepted, rejected: rejected));
}
void _failAllAwaiting(String reason) {
final waiting = List.of(_awaitingAck.values);
_awaitingAck.clear();
for (final c in waiting) {
if (!c.isCompleted) c.completeError(TransportException(reason));
}
}
@override
Future<void> dispose() async {
_failAllAwaiting('The terminal is shutting down.');
await _updates?.cancel();
if (isConnected) {
// A clean goodbye, so head office does not see a till it thinks crashed.
try {
_publish(
config.statusTopic,
jsonEncode({'state': 'offline', 'clean': true}),
retain: true,
);
} on TransportException {
// Already going down; nothing useful to do about it.
}
}
_client?.disconnect();
await _downlink.close();
await _connection.close();
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:async';
/// Raised when a batch could not be handed to the back office.
///
/// Distinct from *rejection*: a transport failure means nobody knows whether
/// the bills arrived, so every row stays pending and is tried again. A
/// rejection means the back office looked at a bill and refused it, which
/// retrying will not fix.
class TransportException implements Exception {
const TransportException(this.message, {this.retryable = true});
final String message;
/// Whether trying again could plausibly succeed. A dropped connection is
/// retryable; a rejected certificate or a bad credential is not, and the
/// engine should stop rather than hammer the broker.
final bool retryable;
@override
String toString() => message;
}
/// What the back office said about one batch.
///
/// [accepted] is the contract that matters: only ids named here are marked
/// synced. Anything absent stays pending, whatever the transport reported at
/// its own layer.
class PushReceipt {
const PushReceipt({required this.accepted, this.rejected = const {}});
final List<String> accepted;
/// Order id → why the back office refused it. Retrying these unchanged will
/// fail again, so the engine surfaces them instead of looping.
final Map<String, String> rejected;
bool get isEmpty => accepted.isEmpty && rejected.isEmpty;
}
/// Something the cloud pushed down to this terminal.
///
/// Only a transport with a live connection can deliver these; request/response
/// transports expose an empty stream.
class DownlinkMessage {
const DownlinkMessage({required this.kind, this.payload = const {}});
final DownlinkKind kind;
final Map<String, Object?> payload;
}
enum DownlinkKind {
/// The catalogue changed at head office — re-import rather than wait for
/// tomorrow morning's pull.
catalogueChanged,
/// Head office is asking this terminal to upload now.
syncRequested,
/// Anything this build does not recognise. Kept rather than dropped so a
/// newer server talking to an older terminal is visible in the events log
/// instead of silently ignored.
unknown,
}
/// How completed bills leave the terminal.
///
/// The drain engine owns *when* to send and what to do when sending fails;
/// this owns only the wire. Swapping HTTP for MQTT is a change of
/// implementation here and nothing else.
abstract class OrderTransport {
/// Shown in the events log so a cashier reporting a problem can say which
/// route the terminal was using.
String get label;
/// Opens the connection. Safe to call when already open.
///
/// A request/response transport has nothing to open and returns at once.
Future<void> connect();
/// Hands a batch over and reports what the back office committed.
///
/// Implementations must not report an id as accepted until the *application*
/// has confirmed it. A broker acknowledging receipt of the bytes is not the
/// back office confirming the sale.
///
/// Throws [TransportException] when the outcome is unknown.
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders);
/// Cloud-initiated messages. Empty for transports that cannot receive.
Stream<DownlinkMessage> get downlink;
/// Whether the route is currently usable. Drives the header's live pill and
/// wakes the drain engine when it flips to true.
Stream<bool> get connectionState;
bool get isConnected;
Future<void> dispose();
}

View File

@@ -0,0 +1,62 @@
import 'dart:async';
import 'order_transport.dart';
/// Stands in for the back office when no broker or endpoint is configured.
///
/// Keeps the terminal demonstrable on a bare laptop: bills queue, drain, and
/// respect the Settings offline switch exactly as they would against a real
/// server, so the drain engine can be exercised without one.
class SimulatedOrderTransport implements OrderTransport {
SimulatedOrderTransport({required this.isOffline});
/// Read on every call rather than copied, so the Settings switch takes effect
/// immediately instead of at the next restart.
final bool Function() isOffline;
final _downlink = StreamController<DownlinkMessage>.broadcast();
final _connection = StreamController<bool>.broadcast();
@override
String get label => 'Simulated';
@override
bool get isConnected => !isOffline();
@override
Stream<DownlinkMessage> get downlink => _downlink.stream;
@override
Stream<bool> get connectionState => _connection.stream;
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
);
if (isOffline()) {
throw const TransportException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
);
}
return PushReceipt(
accepted: orders.map((o) => o['id']! as String).toList(),
);
}
/// Lets the events screen prove the downlink path end to end without a
/// broker.
void emit(DownlinkMessage message) => _downlink.add(message);
@override
Future<void> dispose() async {
await _downlink.close();
await _connection.close();
}
}

View File

@@ -10,13 +10,24 @@ import '../../domain/repositories/sync_repository.dart';
import '../datasources/local_store.dart';
import '../datasources/remote_catalogue_source.dart';
import '../local/order_dao.dart';
import '../remote/order_transport.dart';
class SyncRepositoryImpl implements SyncRepository {
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
SyncRepositoryImpl(
this._store,
this._catalogue,
this._transport, {
this.batchSize = 50,
});
final LocalStore _store;
final RemoteCatalogueSource _catalogue;
final RemoteOrderSink _orderSink;
final OrderTransport _transport;
/// Bills per publish. Kept modest because an MQTT broker will refuse an
/// oversized message outright, and a terminal that has been offline for a
/// day can easily hold hundreds of bills.
final int batchSize;
static const _uuid = Uuid();
@@ -176,13 +187,14 @@ class SyncRepositoryImpl implements SyncRepository {
var attempted = 0;
var uploaded = 0;
var refused = 0;
final syncedInvoices = <String>[];
// `unsynced()` returns a bounded page. Draining it in a loop means a day
// with more bills than one page still uploads completely, instead of
// reporting success with the remainder silently left behind.
while (true) {
final batch = await _store.orders.unsynced();
final batch = await _store.orders.unsynced(limit: batchSize);
if (batch.isEmpty) break;
final ids = batch.map((o) => o.id).toList();
@@ -193,36 +205,19 @@ class SyncRepositoryImpl implements SyncRepository {
'Uploading $attempted of $total bills…',
);
PushReceipt receipt;
try {
final accepted = await _orderSink.pushOrders(
receipt = await _transport.pushOrders(
batch.map(_orderToPayload).toList(),
);
// Only what the server confirmed is archived and removed. Anything it
// did not acknowledge stays on disk.
final acceptedOrders =
batch.where((o) => accepted.contains(o.id)).toList();
await _store.orders.archiveAndDelete(acceptedOrders);
await _store.refreshUnsyncedCount();
uploaded += acceptedOrders.length;
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
final rejected = ids.where((id) => !accepted.contains(id)).toList();
if (rejected.isNotEmpty) {
await _store.orders.markFailed(rejected, 'Rejected by server');
// Rejected rows stay pending, so the next page would return the same
// bills forever. Stop and let the cashier retry.
break;
}
} catch (e) {
// Transport failed: record the attempt but leave every row at 0.
} on Object catch (e) {
// The outcome is unknown, so nothing is marked sent. The attempt is
// recorded against the rows and every one of them stays at 0.
await _store.orders.markFailed(ids, e.toString());
await _store.refreshUnsyncedCount();
final remaining = await _store.orders.unsyncedCount();
final pendingValue =
batch.fold<double>(0, (s, o) => s + o.total);
final pendingValue = batch.fold<double>(0, (s, o) => s + o.total);
await _log(SyncEvent(
id: _uuid.v4(),
@@ -238,29 +233,90 @@ class SyncRepositoryImpl implements SyncRepository {
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
error: e.toString(),
isRetryable: e is! TransportException || e.retryable,
);
}
// Only what the back office named is archived and marked synced.
// Anything it stayed silent about is left pending — silence is not
// acceptance, whatever the transport reported at its own layer.
final acceptedIds = receipt.accepted.toSet();
final acceptedOrders =
batch.where((o) => acceptedIds.contains(o.id)).toList();
await _store.orders.archiveAccepted(acceptedOrders);
await _store.refreshUnsyncedCount();
uploaded += acceptedOrders.length;
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
final unconfirmed = ids.where((id) => !acceptedIds.contains(id)).toList();
if (unconfirmed.isNotEmpty) {
for (final id in unconfirmed) {
await _store.orders.markFailed(
[id],
receipt.rejected[id] ?? 'Not confirmed by the back office',
);
}
refused += unconfirmed.length;
// These rows are still pending, so the next page would hand back the
// same bills forever. Stop, and let a person look at why.
final reasons = receipt.rejected.values.toSet().join('; ');
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.failed,
createdAt: started,
summary: '$uploaded uploaded, ${unconfirmed.length} refused',
error: reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
attempts: 1,
),);
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
error: '${unconfirmed.length} bill(s) were not accepted'
'${reasons.isEmpty ? '' : ': $reasons'}',
// A refusal is a decision, not a fault. Retrying the same bytes gets
// the same answer, so the engine halts instead of looping.
isRetryable: false,
);
}
}
onProgress?.call(0.95, 'Tidying up…');
await purgeExpired();
onProgress?.call(1, 'Done');
// Synced bills are deleted from the terminal, so this log line is the only
// remaining record on the device that they went up.
// Once the retention window closes this log line is the only remaining
// record on the device that these bills went up.
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
summary: '$uploaded of $attempted bills uploaded',
summary: '$uploaded of $attempted bills uploaded '
'via ${_transport.label}',
payload: {'invoices': syncedInvoices},
attempts: 1,
),);
return SyncOutcome(attempted: attempted, uploaded: uploaded);
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
);
}
@override
Future<int> purgeExpired() => _store.orders.purgeSyncedBefore(
DateTime.now().subtract(OrderDao.retentionWindow),
);
/// The JSON body sent per order.
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
'id': t.id,

View File

@@ -0,0 +1,360 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../../domain/repositories/sync_repository.dart';
import '../remote/order_transport.dart';
/// Why a drain was attempted. Shown in the events log, because "why did it try
/// then" is the first question when a sync misbehaves.
enum SyncTrigger {
startup,
saleCommitted,
connectivityRegained,
periodic,
retry,
headOfficeRequest,
manual,
}
/// What the engine is doing, for the header and the events screen.
@immutable
class SyncEngineState {
const SyncEngineState({
this.isSyncing = false,
this.pending = 0,
this.online = true,
this.consecutiveFailures = 0,
this.lastSuccessAt,
this.lastAttemptAt,
this.nextAttemptAt,
this.lastError,
this.isHalted = false,
this.lastTrigger,
});
final bool isSyncing;
final int pending;
final bool online;
final int consecutiveFailures;
final DateTime? lastSuccessAt;
final DateTime? lastAttemptAt;
/// When the backoff timer will fire. Null when nothing is scheduled.
final DateTime? nextAttemptAt;
final String? lastError;
/// Set after a failure that retrying cannot fix — a bad credential, an
/// unconfigured endpoint. The engine stops its own scheduling so it does not
/// hammer a door that is locked; a manual sync or a config change resumes it.
final bool isHalted;
final SyncTrigger? lastTrigger;
bool get isHealthy => !isHalted && consecutiveFailures == 0;
SyncEngineState copyWith({
bool? isSyncing,
int? pending,
bool? online,
int? consecutiveFailures,
DateTime? lastSuccessAt,
DateTime? lastAttemptAt,
DateTime? nextAttemptAt,
String? lastError,
bool? isHalted,
SyncTrigger? lastTrigger,
bool clearNextAttempt = false,
bool clearError = false,
}) =>
SyncEngineState(
isSyncing: isSyncing ?? this.isSyncing,
pending: pending ?? this.pending,
online: online ?? this.online,
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt,
lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt,
nextAttemptAt:
clearNextAttempt ? null : nextAttemptAt ?? this.nextAttemptAt,
lastError: clearError ? null : lastError ?? this.lastError,
isHalted: isHalted ?? this.isHalted,
lastTrigger: lastTrigger ?? this.lastTrigger,
);
}
/// Decides *when* bills are uploaded.
///
/// The repository knows how to send one batch; this knows when to ask, and what
/// to do when the answer is no. Together they turn the orders table into a
/// queue that empties itself:
///
/// * a sale is committed — try immediately, so a bill is usually up within
/// seconds of the drawer closing;
/// * the network returns — try at once rather than waiting out a poll;
/// * nothing happened for a while — poll, because interface state lies;
/// * head office asked — the MQTT downlink can pull a shift up on demand;
/// * the cashier pressed sync — always allowed, even while halted.
///
/// ### Two guarantees worth stating
///
/// **Single flight.** Only one drain runs at a time. Without this, a busy till
/// firing a trigger per sale would have several passes reading the same pending
/// rows and publishing them concurrently — every bill sent two or three times.
/// A trigger arriving mid-drain sets a flag and is honoured once the current
/// pass finishes, so nothing is dropped either.
///
/// **Backoff with jitter.** A failed attempt waits, and waits longer each time,
/// to a ceiling. The jitter matters more than it looks: when a shop's line
/// drops, every terminal in the store fails at the same instant, and without it
/// they would all retry in lockstep and keep colliding on the way back up.
class SyncEngine {
SyncEngine({
required SyncRepository repository,
Stream<bool>? connectivity,
Stream<DownlinkMessage>? downlink,
Future<void> Function()? onCatalogueChanged,
Duration idlePoll = const Duration(minutes: 5),
Duration baseBackoff = const Duration(seconds: 2),
Duration maxBackoff = const Duration(minutes: 5),
Random? random,
DateTime Function()? clock,
Timer Function(Duration, void Function())? scheduleTimer,
}) : _repository = repository,
_connectivity = connectivity,
_downlink = downlink,
_onCatalogueChanged = onCatalogueChanged,
_idlePoll = idlePoll,
_baseBackoff = baseBackoff,
_maxBackoff = maxBackoff,
_random = random ?? Random(),
_now = clock ?? DateTime.now,
_schedule = scheduleTimer ?? Timer.new;
final SyncRepository _repository;
final Stream<bool>? _connectivity;
final Stream<DownlinkMessage>? _downlink;
final Future<void> Function()? _onCatalogueChanged;
final Duration _idlePoll;
final Duration _baseBackoff;
final Duration _maxBackoff;
final Random _random;
final DateTime Function() _now;
final Timer Function(Duration, void Function()) _schedule;
final _states = StreamController<SyncEngineState>.broadcast();
SyncEngineState _state = const SyncEngineState();
SyncEngineState get state => _state;
Stream<SyncEngineState> get states => _states.stream;
/// Held for the whole of a drain. The single-flight guarantee rests on this
/// being checked and set without an `await` in between.
bool _draining = false;
/// A trigger that arrived while a drain was already running.
SyncTrigger? _queuedTrigger;
Timer? _backoffTimer;
Timer? _pollTimer;
StreamSubscription<bool>? _connectivitySub;
StreamSubscription<DownlinkMessage>? _downlinkSub;
bool _stopped = false;
// ------------------------------------------------------------------ Life
Future<void> start() async {
if (_stopped) throw StateError('This SyncEngine has been disposed.');
_connectivitySub = _connectivity?.listen((online) {
_emit(_state.copyWith(online: online));
// Coming back is the single best moment to try; going offline is not
// worth an attempt that is certain to fail.
if (online) nudge(SyncTrigger.connectivityRegained);
});
_downlinkSub = _downlink?.listen(_onDownlink);
_pollTimer = _startPoll();
await _refreshPending();
nudge(SyncTrigger.startup);
}
Timer _startPoll() => _schedule(_idlePoll, () {
if (_stopped) return;
_pollTimer = _startPoll();
nudge(SyncTrigger.periodic);
});
void _onDownlink(DownlinkMessage message) {
switch (message.kind) {
case DownlinkKind.syncRequested:
nudge(SyncTrigger.headOfficeRequest);
case DownlinkKind.catalogueChanged:
// Sending what we owe before pulling new prices keeps the bills we
// already rang priced as they were rung.
nudge(SyncTrigger.headOfficeRequest);
unawaited(_onCatalogueChanged?.call() ?? Future<void>.value());
case DownlinkKind.unknown:
break;
}
}
// -------------------------------------------------------------- Triggers
/// Asks for a drain. Cheap, non-blocking, and safe to call on every sale.
void nudge(SyncTrigger trigger) {
if (_stopped) return;
if (_draining) {
// Remember it rather than dropping it: bills committed during this pass
// were not in the set it read, and would otherwise wait for the poll.
_queuedTrigger = trigger;
return;
}
if (_state.isHalted && trigger != SyncTrigger.manual) return;
if (!_state.online && trigger != SyncTrigger.manual) return;
unawaited(_drain(trigger));
}
/// The cashier pressed sync. Runs even when halted or believed offline —
/// they may know something the engine does not, and being told why it failed
/// beats a button that does nothing.
Future<SyncOutcome> syncNow({
void Function(double progress, String stage)? onProgress,
}) async {
_backoffTimer?.cancel();
_emit(_state.copyWith(isHalted: false, clearNextAttempt: true));
return _drain(SyncTrigger.manual, onProgress: onProgress);
}
// ----------------------------------------------------------------- Drain
Future<SyncOutcome> _drain(
SyncTrigger trigger, {
void Function(double progress, String stage)? onProgress,
}) async {
if (_draining) return const SyncOutcome(attempted: 0, uploaded: 0);
_draining = true;
_backoffTimer?.cancel();
_emit(_state.copyWith(
isSyncing: true,
lastTrigger: trigger,
lastAttemptAt: _now(),
clearNextAttempt: true,
),);
SyncOutcome outcome;
try {
try {
outcome = await _repository.syncOrders(onProgress: onProgress);
} on Object catch (e) {
// The repository is meant to fold failures into the outcome; anything
// escaping it is a defect, not a network fault. Treated as a retryable
// failure so a bad build still empties its queue once fixed.
outcome = SyncOutcome(attempted: 0, uploaded: 0, error: e.toString());
}
await _refreshPending();
if (outcome.isSuccess) {
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: 0,
lastSuccessAt: _now(),
clearError: true,
clearNextAttempt: true,
),);
} else {
_onFailure(outcome);
}
} finally {
// Held until the bookkeeping is done, not just until the send is. Freed
// any earlier, a queued trigger could start a second drain whose
// `isSyncing: true` this one would then overwrite with `false`, leaving
// the header claiming idle while an upload is in flight.
_draining = false;
}
// Honour anything that arrived while we were busy. Bills committed during
// the pass were not in the set it read.
final queued = _queuedTrigger;
_queuedTrigger = null;
if (queued != null && outcome.isSuccess && _state.pending > 0) {
nudge(queued);
}
return outcome;
}
void _onFailure(SyncOutcome outcome) {
final failures = _state.consecutiveFailures + 1;
if (!outcome.isRetryable) {
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: failures,
lastError: outcome.error,
isHalted: true,
clearNextAttempt: true,
),);
return;
}
final delay = backoffFor(failures);
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: failures,
lastError: outcome.error,
nextAttemptAt: _now().add(delay),
),);
_backoffTimer = _schedule(delay, () {
if (_stopped) return;
nudge(SyncTrigger.retry);
});
}
/// Doubles per failure to a ceiling, then ±20% so a store's terminals do not
/// come back in lockstep.
@visibleForTesting
Duration backoffFor(int failures) {
final exponent = (failures - 1).clamp(0, 30);
final raw = _baseBackoff * pow(2, exponent).toDouble();
final capped = raw > _maxBackoff ? _maxBackoff : raw;
final jitter = 0.8 + _random.nextDouble() * 0.4;
return Duration(
milliseconds: (capped.inMilliseconds * jitter).round(),
);
}
Future<void> _refreshPending() async {
try {
// Read first, then emit. Written as `copyWith(pending: await …)` the
// receiver `_state` is evaluated before the await completes, so anything
// that changed during the wait — connectivity dropping, most of all —
// would be overwritten by the stale snapshot.
final count = await _repository.unsyncedCount();
_emit(_state.copyWith(pending: count));
} on Object {
// A count is decoration; failing to read it must not fail the drain.
}
}
void _emit(SyncEngineState next) {
_state = next;
if (!_states.isClosed) _states.add(next);
}
Future<void> dispose() async {
_stopped = true;
_backoffTimer?.cancel();
_pollTimer?.cancel();
await _connectivitySub?.cancel();
await _downlinkSub?.cancel();
await _states.close();
}
}

View File

@@ -2,18 +2,30 @@ import '../entities/shift_report.dart';
import '../entities/sync_event.dart';
import '../entities/transaction.dart';
/// Result of one end-of-day upload.
/// Result of one upload pass.
class SyncOutcome {
const SyncOutcome({
required this.attempted,
required this.uploaded,
this.rejected = 0,
this.error,
this.isRetryable = true,
});
final int attempted;
final int uploaded;
/// Bills the back office looked at and refused. These stay on the terminal
/// but sending them again unchanged will fail again, so they need a person.
final int rejected;
final String? error;
/// Whether trying again could plausibly work. False for a bad credential or
/// an unconfigured endpoint — the drain engine halts rather than retrying
/// something that cannot succeed.
final bool isRetryable;
bool get isSuccess => error == null;
bool get hadNothingToDo => attempted == 0;
int get remaining => attempted - uploaded;
@@ -42,10 +54,11 @@ class OrderSyncRow {
final String? error;
}
/// The terminal's two network touchpoints.
/// The terminal's network touchpoints.
///
/// Morning: pull the catalogue. End of day: upload every order still at
/// `sync_status = 0`. Nothing else leaves the device.
/// Pull the catalogue; upload every order still at `sync_status = 0`. Nothing
/// else leaves the device. *When* the upload runs is not decided here — see
/// `SyncEngine`, which owns triggers and retry policy.
abstract class SyncRepository {
bool get hasCatalogue;
DateTime? get lastImportAt;
@@ -71,12 +84,16 @@ abstract class SyncRepository {
bool scopeToCashier = false,
});
/// End-of-day step — uploads pending orders and flips the accepted ones to
/// `sync_status = 1`. Failures leave every row untouched at 0.
/// One upload pass — sends pending orders and flips the ones the back office
/// confirmed to `sync_status = 1`. Failures leave every row untouched at 0.
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
});
/// Retires confirmed bills past their retention window. Archived totals are
/// untouched.
Future<int> purgeExpired();
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
List<SyncEvent> get events;

View File

@@ -61,11 +61,22 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
if (!store.isReady) return;
final dao = store.catalogue;
// Read every value first, then assign. Written inline the four awaits still
// run before the assignment, so leaving Settings mid-read threw
// "used after dispose" — which reaches the cashier as a red screen.
final url = await dao.meta(MetaKeys.printerUrl);
final name = await dao.meta(MetaKeys.printerName);
final autoPrint = await dao.meta(MetaKeys.autoPrint);
final openDrawer = await dao.meta(MetaKeys.openDrawer);
if (!mounted) return;
state = PrinterSettings(
printerUrl: await dao.meta(MetaKeys.printerUrl),
printerName: await dao.meta(MetaKeys.printerName),
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
printerUrl: url,
printerName: name,
autoPrint: autoPrint == '1',
openDrawer: openDrawer != '0',
);
}
@@ -75,12 +86,14 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
if (printer == null) {
await dao.setMeta(MetaKeys.printerUrl, '');
await dao.setMeta(MetaKeys.printerName, '');
if (!mounted) return;
state = state.copyWith(clearPrinter: true, autoPrint: false);
return;
}
await dao.setMeta(MetaKeys.printerUrl, printer.url);
await dao.setMeta(MetaKeys.printerName, printer.name);
if (!mounted) return;
state = state.copyWith(
printerUrl: printer.url,
printerName: printer.name,
@@ -95,6 +108,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
if (!mounted) return;
state = state.copyWith(autoPrint: value);
}
@@ -103,6 +117,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
if (!mounted) return;
state = state.copyWith(openDrawer: value);
}
}

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/config/sync_config.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../data/local/order_dao.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart';
@@ -318,11 +320,14 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
final config = ref.watch(syncConfigProvider);
final sync = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
return PanelCard(
title: 'Connectivity & sync',
subtitle: 'This terminal only needs a connection to import the '
'catalogue and to push the shift report.',
subtitle: 'Bills are written to this terminal first and uploaded in the '
'background. Nothing is ever held up waiting for the network.',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -331,7 +336,26 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
),
_row('Unsynced bills', '$outstanding'),
_row('Route', _transportLabel(config)),
_row('Waiting to upload', '$outstanding bill(s)'),
_row(
'Last upload',
sync.lastSuccessAt == null
? 'Never'
: Formatters.dateTime(sync.lastSuccessAt!),
),
if (sync.isHalted)
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
else if (sync.nextAttemptAt != null)
_row(
'Next attempt',
'${Formatters.time(sync.nextAttemptAt!)} '
'(attempt ${sync.consecutiveFailures + 1})',
),
_row(
'Bills kept on device',
'${OrderDao.retentionWindow.inDays} days after upload',
),
_toggle(
'Simulate offline',
'Forces import and sync to fail, so you can confirm nothing is '
@@ -340,6 +364,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
ref.watch(simulateOfflineProvider),
(v) {
ref.read(simulateOfflineProvider.notifier).state = v;
// The pill and the drain both read connectivity, so they have to
// be told the switch moved.
ref.read(connectivityServiceProvider).refresh();
// Clear any stale failure banner left by the previous setting.
ref.read(catalogueImportProvider.notifier).reset();
ref.read(orderSyncProvider.notifier).reset();
@@ -350,6 +377,15 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
);
}
String _transportLabel(SyncConfig config) => switch (config.transport) {
TransportKind.simulated =>
'Simulated — no back office configured for this terminal',
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
TransportKind.mqtt =>
'MQTT · ${config.brokerHost}:${config.brokerPort}'
'${config.useTls ? ' (TLS)' : ''}',
};
Widget _aboutCard() => PanelCard(
title: 'About',
child: Column(

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/utils/extensions.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart';
import '../../auth/providers/auth_controller.dart';
@@ -199,6 +200,11 @@ class PaymentController extends StateNotifier<PaymentState> {
_ref.invalidate(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
// The bill is safely on disk; getting it to the back office is the
// engine's problem now. Deliberately not awaited — the cashier must
// reach the receipt screen at network speed of zero.
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
return result;
} on CheckoutFailure catch (e) {
state = state.copyWith(

View File

@@ -154,16 +154,19 @@ class _Breadcrumb extends StatelessWidget {
}
}
class _LivePill extends StatefulWidget {
/// What the pill is saying, in the order it takes precedence.
enum _Liveness { offlineSim, halted, syncing, queued, live }
class _LivePill extends ConsumerStatefulWidget {
const _LivePill({required this.offline});
final bool offline;
@override
State<_LivePill> createState() => _LivePillState();
ConsumerState<_LivePill> createState() => _LivePillState();
}
class _LivePillState extends State<_LivePill>
class _LivePillState extends ConsumerState<_LivePill>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
@@ -178,16 +181,62 @@ class _LivePillState extends State<_LivePill>
@override
Widget build(BuildContext context) {
final offline = widget.offline;
final tone = offline ? AppColors.warning : AppColors.success;
final surface =
offline ? AppColors.warningSurface : AppColors.successSurface;
// The engine may not have emitted yet on a cold start, so fall back to its
// current value rather than showing nothing.
final sync = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
final liveness = switch (0) {
_ when widget.offline => _Liveness.offlineSim,
_ when sync.isHalted => _Liveness.halted,
_ when sync.isSyncing => _Liveness.syncing,
// Bills waiting is normal for a few seconds after a sale; it is only
// worth flagging once they are visibly piling up.
_ when sync.pending > 0 => _Liveness.queued,
_ => _Liveness.live,
};
final (label, tone, surface, message) = switch (liveness) {
_Liveness.offlineSim => (
'OFFLINE (SIM)',
AppColors.warning,
AppColors.warningSurface,
'Simulate offline is ON in Settings — imports and syncs are being '
'failed deliberately.',
),
_Liveness.halted => (
'SYNC HALTED',
AppColors.danger,
AppColors.dangerSurface,
'Uploading stopped because retrying will not help: '
'${sync.lastError ?? 'the back office refused the batch'}. '
'Every bill is still safe on this terminal. Press Sync to try '
'again once it is sorted.',
),
_Liveness.syncing => (
'SYNCING',
AppColors.primary,
AppColors.primarySurface,
'Uploading bills to the back office.',
),
_Liveness.queued => (
'${sync.pending} QUEUED',
AppColors.warning,
AppColors.warningSurface,
'${sync.pending} bill(s) are stored on this terminal and waiting to '
'upload. They are safe; nothing is lost while the line is down.',
),
_Liveness.live => (
'LIVE',
AppColors.success,
AppColors.successSurface,
'Terminal is operating normally and everything rung has been '
'uploaded.',
),
};
return Tooltip(
message: offline
? 'Simulate offline is ON in Settings — imports and syncs are being '
'failed deliberately.'
: 'Terminal is operating normally.',
message: message,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
@@ -210,7 +259,7 @@ class _LivePillState extends State<_LivePill>
),
const SizedBox(width: AppSpacing.xs + 2),
Text(
offline ? 'OFFLINE (SIM)' : 'LIVE',
label,
style: TextStyle(
color: tone,
fontSize: 10.5,

View File

@@ -167,6 +167,12 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
bool get isRunning => state is SyncRunning;
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
///
/// Goes through the engine rather than straight to the repository, so a
/// cashier pressing sync while a background drain is already mid-flight
/// joins it instead of starting a second pass over the same rows. It also
/// clears a halt: pressing the button is how you retry after the back office
/// has been fixed.
Future<SyncOutcome> run() async {
if (isRunning) {
return const SyncOutcome(attempted: 0, uploaded: 0);
@@ -174,7 +180,7 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
state = const SyncRunning(0, 'Starting…');
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
final outcome = await _ref.read(syncEngineProvider).syncNow(
onProgress: (progress, stage) {
if (mounted) state = SyncRunning(progress, stage);
},
@@ -192,3 +198,30 @@ final orderSyncProvider =
StateNotifierProvider<OrderSyncController, OrderSyncState>(
(ref) => OrderSyncController(ref),
);
// ------------------------------------------------------- Background drain
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
///
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
/// bills should be emptying its queue before anyone reaches the till.
///
/// Overridden to a no-op in widget tests, which have no network stack and
/// cannot drive real disk I/O on a fake clock.
final syncBootstrapProvider = FutureProvider<void>((ref) async {
await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider);
// A background drain moves bills out of the pending set, so the tallies and
// shift totals on screen are stale the moment one finishes.
var wasSyncing = false;
final subscription = engine.states.listen((state) {
if (wasSyncing && !state.isSyncing) {
ref.read(orderVersionProvider.notifier).state++;
}
wasSyncing = state.isSyncing;
});
ref.onDispose(subscription.cancel);
await engine.start();
});