iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
An offline-first personal ledger saves every entry to a database on the phone and calculates every total from that database, so recording a payment or checking a balance never waits for a network request. Hishab Ledger, as used here, is a design name for such an app, not a shipped product. This guide sets out how to build it on the pattern Android documents: Room as the local read model, a ViewModel that exposes state, and Jetpack Compose that renders it. Synchronization is treated as an optional layer with its own policy decisions.
What offline-first means for a ledger
Android’s App architecture guidance defines the idea this way: “An offline-first app is an app that is able to perform all, or a critical subset of its core functionality without access to the internet.” The definition leaves the subset to you, so the first design task is to write that subset down. For a personal ledger it usually includes:
- Creating an entry, such as an expense, income record, or transfer between accounts
- Browsing transaction history, newest first, with search by note or category
- Viewing account totals and a running balance
- Editing or correcting an existing entry
- Exporting data, if export is in scope for the first release
Every item in that list must work with the radio off. Anything that needs a server, such as sharing with another device or pulling data from a cloud account, belongs in the sync layer described later and must never block these operations.
Recommended Free Tools
Storage: Room as the source of truth
Android’s persistence options fit different parts of a ledger differently. The table below shows the practical split.
#1 Best Overall
| Option | Best suited to | Fit for ledger data |
|---|---|---|
| Room (over SQLite) | Structured, queryable records with relationships | Primary store for transactions, accounts, and categories. Android’s “Persist data with Room” codelab uses expense and income records as its example. |
| DataStore | Small typed settings and preferences | Default currency, first day of the month, last-used account. Not suited to transaction history. |
| Plain files | Large or unstructured blobs | Exported CSV or backup files, receipt images if you add them. Not a substitute for querying. |
Room is the sensible default for the ledger itself. The following entity is a starting point, not a finished schema:
@Entity(tableName = "transactions")
data class TransactionEntity(
@PrimaryKey val id: String, // client-generated UUID
val accountId: String,
val amountMinor: Long, // integer minor units, e.g. paisa or cents
val currencyCode: String,
val occurredAt: Long, // user-chosen date, epoch millis
val recordedAt: Long, // device time when saved
val note: String?,
val updatedAt: Long,
val deletedAt: Long? // soft delete, so sync can carry it
)
Three choices in that entity carry most of the weight. Store money as integers in minor units rather than floating-point numbers, which cannot represent decimal currency amounts exactly. Generate the primary key on the device so that a retried save cannot create a second row. Use a soft delete (deletedAt) instead of removing rows, because a deletion that never reaches the server is otherwise indistinguishable from data that never existed.
Rank #2
Compute totals in the database layer with SQL aggregates or in a repository function, not in composables. The DAO below returns a Flow, so Room re-emits the list whenever the table changes:
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions WHERE deletedAt IS NULL ORDER BY occurredAt DESC")
fun observeActive(): Flow<List<TransactionEntity>>
@Upsert
suspend fun upsert(tx: TransactionEntity)
}
How data moves from a tap to the screen
Android recommends a ViewModel between the Compose UI and the data layer, with Flow converted to StateFlow where the UI needs a current value. A save operation follows this path:
Rank #3
- The composable calls a callback such as
onSave(draft), passing the user’s input and nothing else. - The ViewModel passes the draft to the repository, which runs validation rules (non-zero amount, a valid account, a date not in an impossible range).
- The repository writes the entity to Room with
upsert. The write goes to the local database first. - Room emits the updated list through the DAO’s Flow, because the
transactionstable changed. - The ViewModel maps that list into a UI state and exposes it as a StateFlow.
- The composable collects the state with
collectAsStateWithLifecycle(), which stops collection when the screen is not visible.
class LedgerViewModel(private val repo: LedgerRepository) : ViewModel() {
val uiState: StateFlow<LedgerUiState> = repo.observeLedger()
.map<List<Entry>, LedgerUiState> { LedgerUiState.Ready(it) }
.catch { emit(LedgerUiState.Error(it.message)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LedgerUiState.Loading)
fun save(draft: EntryDraft) = viewModelScope.launch {
repo.save(draft) // validates, then writes to Room
}
}
@Composable
fun LedgerScreen(vm: LedgerViewModel = viewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
when (val s = state) {
LedgerUiState.Loading -> CircularProgressIndicator()
is LedgerUiState.Ready -> EntryList(s.entries, onSave = vm::save)
is LedgerUiState.Error -> ErrorBanner(s.message)
}
}
Because the screen observes the database and not the network, a save appears immediately, and the same code path works whether or not sync is ever added.
UI states the screen has to show
These states follow from the offline-first model. They are design recommendations for Hishab Ledger rather than behaviour of any existing app:
- Loading: the first read from the local database, which is usually brief but should still show a placeholder.
- Empty ledger: no entries yet, with a clear action to add the first one.
- Saved: the entry is in the local database and needs no further action.
- Saved, waiting to sync: the entry is stored locally and queued for upload. Show this only when sync exists.
- Sync failed: the last upload attempt failed, and local data remains visible and editable.
- Validation error: tied to the specific field, such as a missing amount, so the user can correct it without losing the rest of the draft.
Synchronization is optional and policy-heavy
Offline-first does not require an account or multi-device sync. Add it only when users need it, and treat it as a separate subsystem. Android’s guidance describes pull-based and push-based synchronization and persistent queues. A queue can be kept in Room or DataStore and drained with WorkManager, which handles retries after connectivity returns. Sync also forces decisions that the local architecture does not make for you:
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstall| Decision | Question it answers | Suggested starting point for a ledger |
|---|---|---|
| Refresh direction | How do changes move between devices? | Push queued local writes; pull on app open and on manual refresh. |
| Concurrent edits | What happens when two devices edit the same entry? | Keep both versions and ask the user to resolve, instead of silently overwriting. |
| Deletes versus edits | What if one device deletes an entry another device edits? | Surface the conflict rather than letting either action win silently. |
| Duplicate submissions | What if a retry sends the same entry twice? | Client-generated UUIDs with idempotent upserts on the server. |
| Device clocks | Can a wrong device time reorder history? | Order by the user-chosen occurredAt; keep recordedAt for audit only. |
| Retries | How often and how long should uploads retry? | WorkManager with backoff; show a failure state after a bounded number of attempts. |
A generic last-write-wins rule is the easiest to implement and the easiest to get wrong here. It can silently discard a correction made on another device, which in a ledger means a changed amount disappears without a trace. Only adopt it if you have explicitly accepted that loss.
Best Value
Privacy, backup and recovery decisions
A ledger holds financial history, so these questions need written answers before release:
- Where data lives: state whether the database sits in the app’s private storage on the device and whether anything is copied elsewhere.
- Backup: decide whether Android backup is enabled, what it includes, and whether backed-up data is encrypted. Check these settings against the current Android backup documentation at build time. The sources used for this guide do not establish the protections a given backup configuration provides.
- Device loss: without sync or export, a lost or reset phone means lost entries. Say so in the product, and offer export as a recovery path if it is in scope.
- Export format: a plain CSV with a documented column order is easier to reconcile than a proprietary dump. hledger’s mobile-app page documents mobile ledger apps built around quick entry and export to a computer for reporting, a split that is worth copying in scope decisions.
What the sources establish and what remains open
The architecture and persistence points above come from Android’s developer documentation, including the “Build an offline-first app” guidance in the App architecture section, the “Persist data with Room” codelab, and the local-database guide for keeping data accessible when the device cannot reach a network. Android’s documentation is versioned and changes over time, so confirm API names such as @Upsert and collectAsStateWithLifecycle against the release you build with.
No public Hishab Ledger repository, backend, data model, or specification was found in the sources reviewed for this article, and no performance figures or user statistics for such an app exist in them. The schema, screens, and sync rules shown here are proposals to test against your own requirements, not documented features of an existing product.
The decisions that matter most are the offline subset, the money representation, and the conflict policy. Settle those before writing the first screen.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

