package `in`.carecollect.field.data import android.content.Context import androidx.room.* import java.util.UUID /** * The offline queue. * * A phlebotomist works in stairwells, basements and buildings with no signal. * The rule the whole app is built on: an action taken in the field is written * to this database first and sent to the server second. The screen never waits * for the network, and nothing is ever lost because a POST failed. * * Every queued action carries a UUID that the server uses to deduplicate. That * is what makes a retry safe: send the same collection three times and exactly * one set of barcodes is issued. */ @Entity(tableName = "pending_op") data class PendingOp( @PrimaryKey val clientOpId: String = UUID.randomUUID().toString(), /** Path relative to the API base, e.g. "orders/41/collect". */ val endpoint: String, /** JSON body, already including clientOpId. */ val payload: String, /** Actions apply in the order they were taken, not the order they upload. */ val queuedAt: Long = System.currentTimeMillis(), val attempts: Int = 0, val lastError: String? = null, /** Set once the server has accepted it; kept briefly for the activity log. */ val syncedAt: Long? = null, /** A rejection is final — retrying will not change the answer. */ val rejected: Boolean = false ) @Entity(tableName = "pending_ping") data class PendingPing( @PrimaryKey(autoGenerate = true) val id: Long = 0, val lat: Double, val lng: Double, val accuracy: Int, val speedKmph: Int, val battery: Int, val isMock: Boolean, val orderId: Long? = null, val recordedAt: Long ) /** The day's work, cached so the route list opens instantly and works offline. */ @Entity(tableName = "cached_stop") data class CachedStop( @PrimaryKey val orderId: Long, val orderNo: String, val slotDate: String, val slotStart: String, val slotEnd: String, val status: String, val priority: String, val fasting: Boolean, val patientName: String, val mobile: String, val address: String, val lat: Double?, val lng: Double?, val amount: Double, val paymentStatus: String, /** Full JSON of the stop detail, including the vial checklist. */ val detailJson: String?, val updatedAt: Long = System.currentTimeMillis() ) @Dao interface PendingOpDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun enqueue(op: PendingOp) /** Oldest first — field actions must replay in the order they happened. */ @Query("SELECT * FROM pending_op WHERE syncedAt IS NULL AND rejected = 0 ORDER BY queuedAt ASC LIMIT 40") suspend fun due(): List @Query("SELECT COUNT(*) FROM pending_op WHERE syncedAt IS NULL AND rejected = 0") suspend fun pendingCount(): Int @Query("UPDATE pending_op SET syncedAt = :at WHERE clientOpId = :id") suspend fun markSynced(id: String, at: Long = System.currentTimeMillis()) @Query("UPDATE pending_op SET attempts = attempts + 1, lastError = :err WHERE clientOpId = :id") suspend fun markFailed(id: String, err: String?) @Query("UPDATE pending_op SET rejected = 1, lastError = :err WHERE clientOpId = :id") suspend fun markRejected(id: String, err: String?) /** Anything the server accepted more than a day ago is just clutter. */ @Query("DELETE FROM pending_op WHERE syncedAt IS NOT NULL AND syncedAt < :before") suspend fun prune(before: Long) } @Dao interface PendingPingDao { @Insert suspend fun insert(ping: PendingPing) @Query("SELECT * FROM pending_ping ORDER BY recordedAt ASC LIMIT 120") suspend fun batch(): List @Query("DELETE FROM pending_ping WHERE id IN (:ids)") suspend fun clear(ids: List) @Query("SELECT COUNT(*) FROM pending_ping") suspend fun count(): Int /** * A phone left offline for days would otherwise accumulate a useless * mountain of points. Nobody needs last Tuesday's breadcrumbs. */ @Query("DELETE FROM pending_ping WHERE recordedAt < :before") suspend fun prune(before: Long) } @Dao interface CachedStopDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(stops: List) @Query("SELECT * FROM cached_stop WHERE slotDate = :date ORDER BY slotStart ASC") suspend fun forDate(date: String): List @Query("SELECT * FROM cached_stop WHERE orderId = :id") suspend fun byId(id: Long): CachedStop? /** Optimistic local update so the screen reflects the tap immediately. */ @Query("UPDATE cached_stop SET status = :status WHERE orderId = :id") suspend fun setStatus(id: Long, status: String) @Query("DELETE FROM cached_stop WHERE slotDate < :date") suspend fun pruneBefore(date: String) } @Database( entities = [PendingOp::class, PendingPing::class, CachedStop::class], version = 1, exportSchema = true ) abstract class FieldDb : RoomDatabase() { abstract fun ops(): PendingOpDao abstract fun pings(): PendingPingDao abstract fun stops(): CachedStopDao companion object { @Volatile private var instance: FieldDb? = null fun get(context: Context): FieldDb = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, FieldDb::class.java, "carecollect-field.db" ).build().also { instance = it } } } }