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)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.hilt.navigation.compose)
|
implementation(libs.hilt.navigation.compose)
|
||||||
implementation(libs.hilt.work)
|
implementation(libs.hilt.work)
|
||||||
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
// CameraX + ML Kit (QR scanning)
|
// CameraX + ML Kit (QR scanning)
|
||||||
implementation(libs.camera.core)
|
implementation(libs.camera.core)
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
android:theme="@style/Theme.RewardFlowTerminal"
|
android:theme="@style/Theme.RewardFlowTerminal"
|
||||||
tools:targetApi="35">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
package lu.rewardflow.terminal
|
package lu.rewardflow.terminal
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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
|
@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()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(staffPinId) {
|
||||||
|
viewModel.setStaffPinId(staffPinId)
|
||||||
|
}
|
||||||
|
|
||||||
IdleTracker(
|
IdleTracker(
|
||||||
timeoutMillis = AUTO_LOCK_TIMEOUT_MS,
|
timeoutMillis = AUTO_LOCK_TIMEOUT_MS,
|
||||||
onIdle = onLockScreen,
|
onIdle = onLockScreen,
|
||||||
@@ -95,6 +99,7 @@ private fun TerminalContent(
|
|||||||
TopBar(
|
TopBar(
|
||||||
staffName = staffName,
|
staffName = staffName,
|
||||||
isOnline = state.isOnline,
|
isOnline = state.isOnline,
|
||||||
|
pendingSyncCount = state.pendingSyncCount,
|
||||||
onLockScreen = onLockScreen,
|
onLockScreen = onLockScreen,
|
||||||
)
|
)
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -156,6 +161,7 @@ private fun TerminalContent(
|
|||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
staffName: String,
|
staffName: String,
|
||||||
isOnline: Boolean,
|
isOnline: Boolean,
|
||||||
|
pendingSyncCount: Int,
|
||||||
onLockScreen: () -> Unit,
|
onLockScreen: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -173,6 +179,10 @@ private fun TopBar(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
OnlinePill(isOnline = isOnline)
|
OnlinePill(isOnline = isOnline)
|
||||||
|
if (pendingSyncCount > 0) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
PendingSyncPill(count = pendingSyncCount)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
FilledTonalButton(onClick = onLockScreen) {
|
FilledTonalButton(onClick = onLockScreen) {
|
||||||
Text(stringResource(R.string.terminal_lock))
|
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
|
@Composable
|
||||||
private fun OnlinePill(isOnline: Boolean) {
|
private fun OnlinePill(isOnline: Boolean) {
|
||||||
val (label, color) = if (isOnline)
|
val (label, color) = if (isOnline)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import lu.rewardflow.terminal.data.api.LoyaltyApi
|
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.CardLookupResponse
|
||||||
import lu.rewardflow.terminal.data.model.CategoryItem
|
import lu.rewardflow.terminal.data.model.CategoryItem
|
||||||
import lu.rewardflow.terminal.data.model.EnrollRequest
|
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.network.NetworkMonitor
|
||||||
import lu.rewardflow.terminal.data.repository.CategoryRepository
|
import lu.rewardflow.terminal.data.repository.CategoryRepository
|
||||||
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
||||||
|
import lu.rewardflow.terminal.data.repository.QueueRepository
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,10 +51,20 @@ class TerminalViewModel @Inject constructor(
|
|||||||
private val api: LoyaltyApi,
|
private val api: LoyaltyApi,
|
||||||
private val configRepository: DeviceConfigRepository,
|
private val configRepository: DeviceConfigRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
|
pendingTransactionDao: PendingTransactionDao,
|
||||||
networkMonitor: NetworkMonitor,
|
networkMonitor: NetworkMonitor,
|
||||||
moshi: Moshi,
|
moshi: Moshi,
|
||||||
) : ViewModel() {
|
) : 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> =
|
private val programAdapter: JsonAdapter<ProgramResponse> =
|
||||||
moshi.adapter(ProgramResponse::class.java)
|
moshi.adapter(ProgramResponse::class.java)
|
||||||
|
|
||||||
@@ -61,8 +75,25 @@ class TerminalViewModel @Inject constructor(
|
|||||||
loadCachedProgram()
|
loadCachedProgram()
|
||||||
loadCategories()
|
loadCategories()
|
||||||
refreshRecentTransactions()
|
refreshRecentTransactions()
|
||||||
networkMonitor.isOnline
|
// Kick the sync worker on every screen entry so anything queued in
|
||||||
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
|
// 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)
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +171,15 @@ class TerminalViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Submit the enrollment form. ``birthday`` is expected as an ISO
|
/** Submit the enrollment form. ``birthday`` is expected as an ISO
|
||||||
* ``YYYY-MM-DD`` string or null. The new card is looked up after
|
* ``YYYY-MM-DD`` string or null.
|
||||||
* enrollment so the customer pane renders with the full lookup
|
*
|
||||||
* shape (rewards, can_stamp, cooldown, etc). */
|
* 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(
|
fun submitEnroll(
|
||||||
name: String,
|
name: String,
|
||||||
email: String,
|
email: String,
|
||||||
@@ -154,22 +191,17 @@ class TerminalViewModel @Inject constructor(
|
|||||||
_state.value = _state.value.copy(enrollError = "Name and email are required")
|
_state.value = _state.value.copy(enrollError = "Name and email are required")
|
||||||
return
|
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)
|
_state.value = _state.value.copy(enrolling = true, enrollError = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching { api.enrollCustomer(request) }
|
||||||
api.enrollCustomer(
|
|
||||||
EnrollRequest(
|
|
||||||
email = email.trim(),
|
|
||||||
customer_name = name.trim(),
|
|
||||||
customer_phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
|
||||||
customer_birthday = birthday?.trim()?.takeIf { it.isNotBlank() },
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { card ->
|
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) }
|
runCatching { api.lookupCard(card.card_number) }
|
||||||
.onSuccess { hydrated ->
|
.onSuccess { hydrated ->
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
@@ -180,9 +212,6 @@ class TerminalViewModel @Inject constructor(
|
|||||||
refreshRecentTransactions()
|
refreshRecentTransactions()
|
||||||
}
|
}
|
||||||
.onFailure {
|
.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(
|
_state.value = _state.value.copy(
|
||||||
enrolling = false,
|
enrolling = false,
|
||||||
enrollDialogOpen = false,
|
enrollDialogOpen = false,
|
||||||
@@ -191,10 +220,27 @@ class TerminalViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = { err ->
|
onFailure = { err ->
|
||||||
_state.value = _state.value.copy(
|
if (err is IOException) {
|
||||||
enrolling = false,
|
runCatching { queueRepository.queueEnroll(request, currentStaffPinId) }
|
||||||
enrollError = err.message ?: "Enrollment failed",
|
.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. */
|
/** Add a stamp to the currently-selected card. Queueable offline. */
|
||||||
fun submitStamp(categoryIds: List<Int>) = runAction {
|
fun submitStamp(categoryIds: List<Int>) {
|
||||||
val card = _state.value.customer ?: return@runAction
|
val card = _state.value.customer ?: return
|
||||||
val response = api.addStamp(
|
val request = StampRequest(
|
||||||
StampRequest(
|
card_id = card.id,
|
||||||
card_id = card.id,
|
category_ids = categoryIds.ifEmpty { null },
|
||||||
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. */
|
/** Redeem the stamp reward on the currently-selected card. Online-only. */
|
||||||
fun submitEarnPoints(amountCents: Int, categoryIds: List<Int>) = runAction {
|
fun submitRedeemStamps() = runAction(
|
||||||
if (amountCents <= 0) error("Amount must be greater than 0")
|
block = {
|
||||||
val card = _state.value.customer ?: return@runAction
|
val card = _state.value.customer ?: return@runAction
|
||||||
val response = api.earnPoints(
|
val response = api.redeemStamps(StampRedeemRequest(card_id = card.id))
|
||||||
PointsEarnRequest(
|
if (!response.success) error("Server reported failure")
|
||||||
card_id = card.id,
|
},
|
||||||
purchase_amount_cents = amountCents,
|
queueOnNetworkFailure = null,
|
||||||
category_ids = categoryIds.ifEmpty { 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 {
|
* Run [block] online. On [IOException] (lost network mid-call) and
|
||||||
val card = _state.value.customer ?: return@runAction
|
* with a non-null [queueOnNetworkFailure], the action is queued via
|
||||||
val response = api.redeemStamps(
|
* [QueueRepository] and the dialog reports queued-success. On
|
||||||
StampRedeemRequest(card_id = card.id)
|
* [HttpException] / other failures the dialog surfaces the server's
|
||||||
)
|
* message — those are real errors, not connectivity blips.
|
||||||
if (!response.success) error("Server reported failure")
|
*/
|
||||||
}
|
private fun runAction(
|
||||||
|
block: suspend () -> Unit,
|
||||||
/** Redeem a points reward by id. */
|
queueOnNetworkFailure: (suspend () -> Unit)?,
|
||||||
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) {
|
|
||||||
if (_state.value.actionInProgress) return
|
if (_state.value.actionInProgress) return
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
actionInProgress = true,
|
actionInProgress = true,
|
||||||
@@ -291,14 +364,34 @@ class TerminalViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
refreshCurrentCustomer()
|
refreshCurrentCustomer()
|
||||||
refreshRecentTransactions()
|
refreshRecentTransactions()
|
||||||
} else {
|
return@launch
|
||||||
_state.value = _state.value.copy(
|
|
||||||
actionInProgress = false,
|
|
||||||
actionResult = ActionResult.Failure(
|
|
||||||
readableErrorMessage(result.exceptionOrNull())
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 enrollDialogOpen: Boolean = false,
|
||||||
val enrolling: Boolean = false,
|
val enrolling: Boolean = false,
|
||||||
val enrollError: String? = null,
|
val enrollError: String? = null,
|
||||||
|
val pendingSyncCount: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
||||||
|
|
||||||
sealed interface ActionResult {
|
sealed interface ActionResult {
|
||||||
data object Success : 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
|
data class Failure(val message: String) : ActionResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ room = "2.6.1"
|
|||||||
workManager = "2.10.0"
|
workManager = "2.10.0"
|
||||||
|
|
||||||
# Dependency Injection
|
# 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"
|
hiltNavigationCompose = "1.2.0"
|
||||||
|
|
||||||
# Camera & QR scanning
|
# 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-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-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
hilt-work = { group = "androidx.hilt", name = "hilt-work", 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)
|
# CameraX (QR scanning)
|
||||||
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
|
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
|
||||||
|
|||||||
Reference in New Issue
Block a user