feat(android-terminal): Phase E — offline queue + sync
Some checks failed
Some checks failed
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
_state.value = _state.value.copy(enrolling = true, enrollError = null)
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
api.enrollCustomer(
|
||||
EnrollRequest(
|
||||
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(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 ->
|
||||
if (err is IOException) {
|
||||
runCatching { queueRepository.queueEnroll(request, currentStaffPinId) }
|
||||
.onSuccess {
|
||||
_state.value = _state.value.copy(
|
||||
enrolling = false,
|
||||
enrollError = err.message ?: "Enrollment failed",
|
||||
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(
|
||||
/** 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. */
|
||||
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(
|
||||
/** 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) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Redeem the stamp reward on the currently-selected card. */
|
||||
fun submitRedeemStamps() = runAction {
|
||||
/** 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)
|
||||
)
|
||||
val response = api.redeemStamps(StampRedeemRequest(card_id = card.id))
|
||||
if (!response.success) error("Server reported failure")
|
||||
}
|
||||
},
|
||||
queueOnNetworkFailure = null,
|
||||
)
|
||||
|
||||
/** Redeem a points reward by id. */
|
||||
fun submitRedeemReward(rewardId: String) = runAction {
|
||||
/** 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")
|
||||
}
|
||||
},
|
||||
queueOnNetworkFailure = null,
|
||||
)
|
||||
|
||||
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 {
|
||||
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(
|
||||
readableErrorMessage(result.exceptionOrNull())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user