diff --git a/clients/terminal-android/app/build.gradle.kts b/clients/terminal-android/app/build.gradle.kts
index bb19101d..0a539454 100644
--- a/clients/terminal-android/app/build.gradle.kts
+++ b/clients/terminal-android/app/build.gradle.kts
@@ -90,6 +90,7 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
// CameraX + ML Kit (QR scanning)
implementation(libs.camera.core)
diff --git a/clients/terminal-android/app/src/main/AndroidManifest.xml b/clients/terminal-android/app/src/main/AndroidManifest.xml
index a6cb5f08..eefee6ee 100644
--- a/clients/terminal-android/app/src/main/AndroidManifest.xml
+++ b/clients/terminal-android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,19 @@
android:theme="@style/Theme.RewardFlowTerminal"
tools:targetApi="35">
+
+
+
+
+
`` removal under ``androidx.startup``); WorkManager
+ * picks up our [Configuration] via this provider on first access.
+ */
@HiltAndroidApp
-class RewardFlowApp : Application()
+class RewardFlowApp : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+}
diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/QueueRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/QueueRepository.kt
new file mode 100644
index 00000000..53a070a0
--- /dev/null
+++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/QueueRepository.kt
@@ -0,0 +1,100 @@
+package lu.rewardflow.terminal.data.repository
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import com.squareup.moshi.Moshi
+import dagger.hilt.android.qualifiers.ApplicationContext
+import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
+import lu.rewardflow.terminal.data.db.entity.PendingTransaction
+import lu.rewardflow.terminal.data.model.EnrollRequest
+import lu.rewardflow.terminal.data.model.PointsEarnRequest
+import lu.rewardflow.terminal.data.model.StampRequest
+import lu.rewardflow.terminal.data.sync.SyncWorker
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Single entrypoint for "queue an action that failed offline".
+ *
+ * The terminal screen calls one of [queueStamp] / [queuePointsEarn] /
+ * [queueEnroll] after detecting an [java.io.IOException] mid-action.
+ * The body is serialized to JSON, persisted via [PendingTransactionDao],
+ * and a [SyncWorker] is enqueued under a network constraint so the
+ * system runs it as soon as connectivity returns.
+ *
+ * Redemption flows are NOT queued here — the plan calls for them to
+ * stay online-only since balances at redeem time can't be retro'd.
+ */
+@Singleton
+class QueueRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val dao: PendingTransactionDao,
+ moshi: Moshi,
+) {
+ private val stampAdapter = moshi.adapter(StampRequest::class.java)
+ private val earnAdapter = moshi.adapter(PointsEarnRequest::class.java)
+ private val enrollAdapter = moshi.adapter(EnrollRequest::class.java)
+
+ suspend fun queueStamp(request: StampRequest, staffPinId: Int) {
+ dao.insert(
+ PendingTransaction(
+ type = SyncWorker.TYPE_STAMP,
+ requestJson = stampAdapter.toJson(request),
+ staffPinId = staffPinId,
+ )
+ )
+ scheduleSync()
+ }
+
+ suspend fun queuePointsEarn(request: PointsEarnRequest, staffPinId: Int) {
+ dao.insert(
+ PendingTransaction(
+ type = SyncWorker.TYPE_POINTS_EARN,
+ requestJson = earnAdapter.toJson(request),
+ staffPinId = staffPinId,
+ )
+ )
+ scheduleSync()
+ }
+
+ suspend fun queueEnroll(request: EnrollRequest, staffPinId: Int) {
+ dao.insert(
+ PendingTransaction(
+ type = SyncWorker.TYPE_ENROLL,
+ requestJson = enrollAdapter.toJson(request),
+ staffPinId = staffPinId,
+ )
+ )
+ scheduleSync()
+ }
+
+ /** Kick the sync worker. Idempotent — KEEP policy means a queued or
+ * running request isn't displaced. The system runs us when the
+ * network constraint is satisfied. */
+ fun scheduleSync() {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ UNIQUE_SYNC_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ }
+
+ companion object {
+ const val UNIQUE_SYNC_WORK_NAME = "loyalty_sync"
+ }
+}
diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/sync/SyncWorker.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/sync/SyncWorker.kt
new file mode 100644
index 00000000..5f287006
--- /dev/null
+++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/sync/SyncWorker.kt
@@ -0,0 +1,134 @@
+package lu.rewardflow.terminal.data.sync
+
+import android.content.Context
+import android.util.Log
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.squareup.moshi.Moshi
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import lu.rewardflow.terminal.data.api.LoyaltyApi
+import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
+import lu.rewardflow.terminal.data.db.entity.PendingTransaction
+import lu.rewardflow.terminal.data.model.EnrollRequest
+import lu.rewardflow.terminal.data.model.PointsEarnRequest
+import lu.rewardflow.terminal.data.model.StampRequest
+import retrofit2.HttpException
+import java.io.IOException
+
+/**
+ * Drains the `pending_transactions` queue against the loyalty API.
+ *
+ * Scheduled by [QueueRepository.scheduleSync] when:
+ * - A new action is queued (immediate try)
+ * - The system reports the network became available again
+ *
+ * Behavior per row:
+ * - HTTP success → mark synced, deleted at the end of the run.
+ * - [HttpException] (4xx/5xx) → permanent fail; the action's data is
+ * stale or rejected. Mark failed and stop retrying it.
+ * - [IOException] → transient (lost network mid-flight). Leave row in
+ * `pending` and return `Result.retry()` so WorkManager re-runs us
+ * under the network constraint with backoff.
+ *
+ * Order is FIFO by `createdAt` so a stamp queued before a redeem stays
+ * before a redeem in the run order — though we don't currently queue
+ * redeems anyway.
+ */
+@HiltWorker
+class SyncWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val dao: PendingTransactionDao,
+ private val api: LoyaltyApi,
+ private val moshi: Moshi,
+) : CoroutineWorker(appContext, workerParams) {
+
+ private val stampAdapter = moshi.adapter(StampRequest::class.java)
+ private val earnAdapter = moshi.adapter(PointsEarnRequest::class.java)
+ private val enrollAdapter = moshi.adapter(EnrollRequest::class.java)
+
+ override suspend fun doWork(): Result {
+ val pending = dao.getPending()
+ if (pending.isEmpty()) return Result.success()
+
+ var hadTransientFailure = false
+ for (tx in pending) {
+ when (process(tx)) {
+ ProcessOutcome.Synced -> dao.markSynced(tx.id)
+ ProcessOutcome.PermanentFailure -> {
+ // markFailed() bumps retryCount + records the error.
+ // Already handled inside process(). Move on to the next.
+ }
+ ProcessOutcome.Transient -> {
+ hadTransientFailure = true
+ // Stop draining — we've lost the network mid-run.
+ break
+ }
+ }
+ }
+
+ // Sweep synced rows out of the table so the queue size stays
+ // bounded over a long-running offline session.
+ runCatching { dao.deleteSynced() }
+
+ return if (hadTransientFailure) Result.retry() else Result.success()
+ }
+
+ private suspend fun process(tx: PendingTransaction): ProcessOutcome {
+ return try {
+ when (tx.type) {
+ TYPE_STAMP -> {
+ val req = stampAdapter.fromJson(tx.requestJson)
+ if (req == null) {
+ dao.markFailed(tx.id, "Malformed queued payload")
+ return ProcessOutcome.PermanentFailure
+ }
+ api.addStamp(req)
+ }
+ TYPE_POINTS_EARN -> {
+ val req = earnAdapter.fromJson(tx.requestJson)
+ if (req == null) {
+ dao.markFailed(tx.id, "Malformed queued payload")
+ return ProcessOutcome.PermanentFailure
+ }
+ api.earnPoints(req)
+ }
+ TYPE_ENROLL -> {
+ val req = enrollAdapter.fromJson(tx.requestJson)
+ if (req == null) {
+ dao.markFailed(tx.id, "Malformed queued payload")
+ return ProcessOutcome.PermanentFailure
+ }
+ api.enrollCustomer(req)
+ }
+ else -> {
+ dao.markFailed(tx.id, "Unknown type: ${tx.type}")
+ return ProcessOutcome.PermanentFailure
+ }
+ }
+ ProcessOutcome.Synced
+ } catch (e: HttpException) {
+ Log.w(TAG, "HTTP ${e.code()} syncing tx ${tx.id} (${tx.type})", e)
+ dao.markFailed(tx.id, "HTTP ${e.code()}: ${e.message()}")
+ ProcessOutcome.PermanentFailure
+ } catch (e: IOException) {
+ Log.w(TAG, "Network error syncing tx ${tx.id} — will retry", e)
+ ProcessOutcome.Transient
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error syncing tx ${tx.id}", e)
+ dao.markFailed(tx.id, e.message ?: e::class.simpleName ?: "Unknown error")
+ ProcessOutcome.PermanentFailure
+ }
+ }
+
+ private enum class ProcessOutcome { Synced, PermanentFailure, Transient }
+
+ companion object {
+ const val TYPE_STAMP = "stamp"
+ const val TYPE_POINTS_EARN = "points_earn"
+ const val TYPE_ENROLL = "enroll"
+ private const val TAG = "SyncWorker"
+ }
+}
diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt
index 75a4c712..542b1f78 100644
--- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt
+++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt
@@ -69,6 +69,10 @@ fun TerminalScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ androidx.compose.runtime.LaunchedEffect(staffPinId) {
+ viewModel.setStaffPinId(staffPinId)
+ }
+
IdleTracker(
timeoutMillis = AUTO_LOCK_TIMEOUT_MS,
onIdle = onLockScreen,
@@ -95,6 +99,7 @@ private fun TerminalContent(
TopBar(
staffName = staffName,
isOnline = state.isOnline,
+ pendingSyncCount = state.pendingSyncCount,
onLockScreen = onLockScreen,
)
Row(modifier = Modifier.fillMaxSize()) {
@@ -156,6 +161,7 @@ private fun TerminalContent(
private fun TopBar(
staffName: String,
isOnline: Boolean,
+ pendingSyncCount: Int,
onLockScreen: () -> Unit,
) {
Surface(
@@ -173,6 +179,10 @@ private fun TopBar(
)
Spacer(Modifier.size(16.dp))
OnlinePill(isOnline = isOnline)
+ if (pendingSyncCount > 0) {
+ Spacer(Modifier.size(8.dp))
+ PendingSyncPill(count = pendingSyncCount)
+ }
Spacer(modifier = Modifier.weight(1f))
FilledTonalButton(onClick = onLockScreen) {
Text(stringResource(R.string.terminal_lock))
@@ -181,6 +191,23 @@ private fun TopBar(
}
}
+@Composable
+private fun PendingSyncPill(count: Int) {
+ val color = MaterialTheme.colorScheme.tertiary
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = color.copy(alpha = 0.18f),
+ ) {
+ Text(
+ text = stringResource(R.string.pin_pending_sync, count),
+ color = color,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ )
+ }
+}
+
@Composable
private fun OnlinePill(isOnline: Boolean) {
val (label, color) = if (isOnline)
diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt
index 04d09e49..12327802 100644
--- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt
+++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt
@@ -12,7 +12,9 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.combine
import lu.rewardflow.terminal.data.api.LoyaltyApi
+import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
import lu.rewardflow.terminal.data.model.CardLookupResponse
import lu.rewardflow.terminal.data.model.CategoryItem
import lu.rewardflow.terminal.data.model.EnrollRequest
@@ -25,7 +27,9 @@ import lu.rewardflow.terminal.data.model.TransactionItem
import lu.rewardflow.terminal.data.network.NetworkMonitor
import lu.rewardflow.terminal.data.repository.CategoryRepository
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
+import lu.rewardflow.terminal.data.repository.QueueRepository
import retrofit2.HttpException
+import java.io.IOException
import javax.inject.Inject
/**
@@ -47,10 +51,20 @@ class TerminalViewModel @Inject constructor(
private val api: LoyaltyApi,
private val configRepository: DeviceConfigRepository,
private val categoryRepository: CategoryRepository,
+ private val queueRepository: QueueRepository,
+ pendingTransactionDao: PendingTransactionDao,
networkMonitor: NetworkMonitor,
moshi: Moshi,
) : ViewModel() {
+ /** Set by [TerminalScreen] from the route arguments so queued
+ * transactions can record which staff initiated the offline action. */
+ private var currentStaffPinId: Int = 0
+
+ fun setStaffPinId(id: Int) {
+ currentStaffPinId = id
+ }
+
private val programAdapter: JsonAdapter =
moshi.adapter(ProgramResponse::class.java)
@@ -61,8 +75,25 @@ class TerminalViewModel @Inject constructor(
loadCachedProgram()
loadCategories()
refreshRecentTransactions()
- networkMonitor.isOnline
- .onEach { online -> _state.value = _state.value.copy(isOnline = online) }
+ // Kick the sync worker on every screen entry so anything queued in
+ // a previous session drains as soon as the network constraint is
+ // satisfied. Idempotent — KEEP policy in scheduleSync().
+ queueRepository.scheduleSync()
+
+ combine(
+ networkMonitor.isOnline,
+ pendingTransactionDao.getPendingCount(),
+ ) { online, pending -> online to pending }
+ .onEach { (online, pending) ->
+ _state.value = _state.value.copy(
+ isOnline = online,
+ pendingSyncCount = pending,
+ )
+ // When the count drops to 0 after being non-zero, the
+ // queue just drained; refresh the customer + feed so the
+ // UI reflects the synced changes.
+ if (pending == 0) refreshRecentTransactions()
+ }
.launchIn(viewModelScope)
}
@@ -140,9 +171,15 @@ class TerminalViewModel @Inject constructor(
}
/** Submit the enrollment form. ``birthday`` is expected as an ISO
- * ``YYYY-MM-DD`` string or null. The new card is looked up after
- * enrollment so the customer pane renders with the full lookup
- * shape (rewards, can_stamp, cooldown, etc). */
+ * ``YYYY-MM-DD`` string or null.
+ *
+ * Online: enrolls + re-fetches the lookup-shape card so the
+ * customer pane renders fully.
+ *
+ * Offline (IOException): queues the enrollment for later sync;
+ * the dialog closes and the cashier sees a "queued" toast. The
+ * customer pane is NOT pre-populated since we don't know the
+ * card_number until the server assigns one. */
fun submitEnroll(
name: String,
email: String,
@@ -154,22 +191,17 @@ class TerminalViewModel @Inject constructor(
_state.value = _state.value.copy(enrollError = "Name and email are required")
return
}
+ val request = EnrollRequest(
+ email = email.trim(),
+ customer_name = name.trim(),
+ customer_phone = phone?.trim()?.takeIf { it.isNotBlank() },
+ customer_birthday = birthday?.trim()?.takeIf { it.isNotBlank() },
+ )
_state.value = _state.value.copy(enrolling = true, enrollError = null)
viewModelScope.launch {
- val result = runCatching {
- api.enrollCustomer(
- EnrollRequest(
- email = email.trim(),
- customer_name = name.trim(),
- customer_phone = phone?.trim()?.takeIf { it.isNotBlank() },
- customer_birthday = birthday?.trim()?.takeIf { it.isNotBlank() },
- )
- )
- }
+ val result = runCatching { api.enrollCustomer(request) }
result.fold(
onSuccess = { card ->
- // Re-fetch with the lookup shape so the customer pane has
- // the full set of fields (rewards, cooldown, etc).
runCatching { api.lookupCard(card.card_number) }
.onSuccess { hydrated ->
_state.value = _state.value.copy(
@@ -180,9 +212,6 @@ class TerminalViewModel @Inject constructor(
refreshRecentTransactions()
}
.onFailure {
- // Card was enrolled — but we couldn't fetch the
- // hydrated view. Close the dialog anyway and let
- // the user search by the new card_number.
_state.value = _state.value.copy(
enrolling = false,
enrollDialogOpen = false,
@@ -191,10 +220,27 @@ class TerminalViewModel @Inject constructor(
}
},
onFailure = { err ->
- _state.value = _state.value.copy(
- enrolling = false,
- enrollError = err.message ?: "Enrollment failed",
- )
+ if (err is IOException) {
+ runCatching { queueRepository.queueEnroll(request, currentStaffPinId) }
+ .onSuccess {
+ _state.value = _state.value.copy(
+ enrolling = false,
+ enrollDialogOpen = false,
+ actionResult = ActionResult.Queued,
+ )
+ }
+ .onFailure { qErr ->
+ _state.value = _state.value.copy(
+ enrolling = false,
+ enrollError = qErr.message ?: "Could not queue enrollment",
+ )
+ }
+ } else {
+ _state.value = _state.value.copy(
+ enrolling = false,
+ enrollError = readableErrorMessage(err),
+ )
+ }
},
)
}
@@ -231,51 +277,78 @@ class TerminalViewModel @Inject constructor(
)
}
- /** Add a stamp to the currently-selected card. */
- fun submitStamp(categoryIds: List) = runAction {
- val card = _state.value.customer ?: return@runAction
- val response = api.addStamp(
- StampRequest(
- card_id = card.id,
- category_ids = categoryIds.ifEmpty { null },
+ /** Add a stamp to the currently-selected card. Queueable offline. */
+ fun submitStamp(categoryIds: List) {
+ val card = _state.value.customer ?: return
+ val request = StampRequest(
+ card_id = card.id,
+ category_ids = categoryIds.ifEmpty { null },
+ )
+ runAction(
+ block = {
+ val response = api.addStamp(request)
+ if (!response.success) error("Server reported failure")
+ },
+ queueOnNetworkFailure = { queueRepository.queueStamp(request, currentStaffPinId) },
+ )
+ }
+
+ /** Earn points on a purchase. [amountCents] must be > 0. Queueable offline. */
+ fun submitEarnPoints(amountCents: Int, categoryIds: List) {
+ if (amountCents <= 0) {
+ _state.value = _state.value.copy(
+ actionResult = ActionResult.Failure("Amount must be greater than 0")
)
+ return
+ }
+ val card = _state.value.customer ?: return
+ val request = PointsEarnRequest(
+ card_id = card.id,
+ purchase_amount_cents = amountCents,
+ category_ids = categoryIds.ifEmpty { null },
+ )
+ runAction(
+ block = {
+ val response = api.earnPoints(request)
+ if (!response.success) error("Server reported failure")
+ },
+ queueOnNetworkFailure = { queueRepository.queuePointsEarn(request, currentStaffPinId) },
)
- if (!response.success) error("Server reported failure")
}
- /** Earn points on a purchase. [amountCents] must be > 0. */
- fun submitEarnPoints(amountCents: Int, categoryIds: List) = runAction {
- if (amountCents <= 0) error("Amount must be greater than 0")
- val card = _state.value.customer ?: return@runAction
- val response = api.earnPoints(
- PointsEarnRequest(
- card_id = card.id,
- purchase_amount_cents = amountCents,
- category_ids = categoryIds.ifEmpty { null },
+ /** Redeem the stamp reward on the currently-selected card. Online-only. */
+ fun submitRedeemStamps() = runAction(
+ block = {
+ val card = _state.value.customer ?: return@runAction
+ val response = api.redeemStamps(StampRedeemRequest(card_id = card.id))
+ if (!response.success) error("Server reported failure")
+ },
+ queueOnNetworkFailure = null,
+ )
+
+ /** Redeem a points reward by id. Online-only. */
+ fun submitRedeemReward(rewardId: String) = runAction(
+ block = {
+ val card = _state.value.customer ?: return@runAction
+ val response = api.redeemPoints(
+ PointsRedeemRequest(card_id = card.id, reward_id = rewardId)
)
- )
- if (!response.success) error("Server reported failure")
- }
+ if (!response.success) error("Server reported failure")
+ },
+ queueOnNetworkFailure = null,
+ )
- /** Redeem the stamp reward on the currently-selected card. */
- fun submitRedeemStamps() = runAction {
- val card = _state.value.customer ?: return@runAction
- val response = api.redeemStamps(
- StampRedeemRequest(card_id = card.id)
- )
- if (!response.success) error("Server reported failure")
- }
-
- /** Redeem a points reward by id. */
- fun submitRedeemReward(rewardId: String) = runAction {
- val card = _state.value.customer ?: return@runAction
- val response = api.redeemPoints(
- PointsRedeemRequest(card_id = card.id, reward_id = rewardId)
- )
- if (!response.success) error("Server reported failure")
- }
-
- private fun runAction(block: suspend () -> Unit) {
+ /**
+ * Run [block] online. On [IOException] (lost network mid-call) and
+ * with a non-null [queueOnNetworkFailure], the action is queued via
+ * [QueueRepository] and the dialog reports queued-success. On
+ * [HttpException] / other failures the dialog surfaces the server's
+ * message — those are real errors, not connectivity blips.
+ */
+ private fun runAction(
+ block: suspend () -> Unit,
+ queueOnNetworkFailure: (suspend () -> Unit)?,
+ ) {
if (_state.value.actionInProgress) return
_state.value = _state.value.copy(
actionInProgress = true,
@@ -291,14 +364,34 @@ class TerminalViewModel @Inject constructor(
)
refreshCurrentCustomer()
refreshRecentTransactions()
- } else {
- _state.value = _state.value.copy(
- actionInProgress = false,
- actionResult = ActionResult.Failure(
- readableErrorMessage(result.exceptionOrNull())
- ),
- )
+ return@launch
}
+
+ val err = result.exceptionOrNull()
+ if (err is IOException && queueOnNetworkFailure != null) {
+ runCatching { queueOnNetworkFailure() }
+ .onSuccess {
+ _state.value = _state.value.copy(
+ actionInProgress = false,
+ actionResult = ActionResult.Queued,
+ activeAction = null,
+ )
+ }
+ .onFailure { qErr ->
+ _state.value = _state.value.copy(
+ actionInProgress = false,
+ actionResult = ActionResult.Failure(
+ qErr.message ?: "Could not queue offline"
+ ),
+ )
+ }
+ return@launch
+ }
+
+ _state.value = _state.value.copy(
+ actionInProgress = false,
+ actionResult = ActionResult.Failure(readableErrorMessage(err)),
+ )
}
}
@@ -386,11 +479,17 @@ data class TerminalUiState(
val enrollDialogOpen: Boolean = false,
val enrolling: Boolean = false,
val enrollError: String? = null,
+ val pendingSyncCount: Int = 0,
)
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
sealed interface ActionResult {
data object Success : ActionResult
+
+ /** The action couldn't reach the server but was queued in Room and
+ * will sync when connectivity returns. The cashier sees a "queued"
+ * message instead of a hard failure. */
+ data object Queued : ActionResult
data class Failure(val message: String) : ActionResult
}
diff --git a/clients/terminal-android/gradle/libs.versions.toml b/clients/terminal-android/gradle/libs.versions.toml
index 30eb9262..d29fb2c9 100644
--- a/clients/terminal-android/gradle/libs.versions.toml
+++ b/clients/terminal-android/gradle/libs.versions.toml
@@ -34,7 +34,11 @@ room = "2.6.1"
workManager = "2.10.0"
# Dependency Injection
-hilt = "2.51.1"
+# Dagger 2.55 added support for Kotlin 2.1 metadata. Older versions
+# (2.51 and below) crash when Dagger validates @Inject fields on classes
+# compiled by the Kotlin 2.1 toolchain — surfaces as
+# "Unable to read Kotlin metadata due to unsupported metadata version".
+hilt = "2.55"
hiltNavigationCompose = "1.2.0"
# Camera & QR scanning
@@ -92,6 +96,10 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
+# Annotation processor for @HiltWorker — separate from the Dagger compiler
+# above. Generates the WorkerFactory binding so workers get constructor
+# injection like every other @Inject class.
+androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" }
# CameraX (QR scanning)
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }