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