feat(android-terminal): Phase E — offline queue + sync
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Stamp / earn / enroll actions performed without connectivity now persist
in the existing pending_transactions Room table and drain via a
SyncWorker as soon as the network constraint is satisfied. Redemption
stays online-only (server needs the authoritative balance — queueing
would let cashiers redeem rewards customers have already spent).

Pieces:

- Hilt-Work plumbing: androidx.hilt:hilt-compiler KSP processor wired,
  RewardFlowApp now implements Configuration.Provider with the injected
  HiltWorkerFactory, AndroidManifest disables the WorkManager auto-init
  via the AndroidX startup tools:node="remove" pattern. Bumped Dagger
  to 2.55 so its compiler can read Kotlin 2.1 metadata — 2.51 crashed
  with "Unable to read Kotlin metadata due to unsupported metadata
  version" once we added @Inject lateinit var on the Application class.

- data/sync/SyncWorker.kt — @HiltWorker CoroutineWorker that drains the
  queue FIFO. Per row: HTTP error → permanent markFailed; IOException
  → Result.retry(); success → markSynced + sweep at end of run.

- data/repository/QueueRepository.kt — single entrypoint for "queue
  this offline action". queueStamp / queuePointsEarn / queueEnroll
  Moshi-serialize the request body and enqueueUniqueWork(KEEP) the
  worker under a NetworkType.CONNECTED constraint with 30s exponential
  backoff. scheduleSync() is idempotent.

- TerminalViewModel: runAction(block, queueOnNetworkFailure) — on
  IOException AND a queue lambda is provided, queue + emit
  ActionResult.Queued. HttpException paths still surface the server's
  message inline. Stamp / Earn / Enroll go through the queue path;
  redeem actions pass null. setStaffPinId records who initiated each
  queued row. init schedules a sync so anything left from a previous
  session drains on screen entry. pendingSyncCount is combined into
  state via Flow<Int> from the DAO; when it drops to 0 we refresh the
  recent-transactions feed so the UI shows what just synced.

- TerminalScreen: PendingSyncPill in the top bar (only visible when
  count > 0) gives the cashier feedback that a queued action is
  waiting + that the queue is draining live.

Cleartext + readable HTTP errors from yesterday remain in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:54:42 +02:00
parent c158d920d2
commit ac5f46cff3
8 changed files with 474 additions and 72 deletions

View File

@@ -19,6 +19,19 @@
android:theme="@style/Theme.RewardFlowTerminal"
tools:targetApi="35">
<!-- Disable WorkManager's default initializer; RewardFlowApp
implements Configuration.Provider with the HiltWorkerFactory. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,7 +1,27 @@
package lu.rewardflow.terminal
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* Application entry point. Wires Hilt's [HiltWorkerFactory] into
* WorkManager so [@HiltWorker] classes get constructor injection.
*
* The default WorkManager auto-initializer is disabled in the manifest
* (see ``<provider>`` 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()
}

View File

@@ -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<SyncWorker>()
.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"
}
}

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -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<ProgramResponse> =
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<Int>) = 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<Int>) {
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<Int>) {
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<Int>) = 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
}