A personal finance tracker for Android with multi-currency support and live exchange rates. Log expenses in any currency, see a spending breakdown normalized into a display currency of your choice, convert currencies with live rates, and glance at today's spend from a home-screen widget.
Built with Kotlin, Jetpack Compose (Material 3), MVVM + a thin domain layer, Hilt, Room, Retrofit, Coroutines/Flow, and Jetpack Glance — following a test-first approach for the money math.
Author: Manas Ranjan Rao
| Screen | Highlights |
|---|---|
| Dashboard | Total spend for a period (Week / Month / All), an animated donut chart of spend-by-category, category cards with %, and a display-currency selector that re-normalizes every figure via live rates. |
| Transactions | Chronological list grouped by day with sticky headers and per-day subtotals, original + converted amounts, swipe-to-delete with Undo, and category filtering. |
| Add / Edit | Validated form (amount, currency, category, note, date). Save is disabled until valid; editing prefills the form. |
| Converter | Live conversion as you type (debounced), a swap button, the rate used and its date, and offline-safe behavior. |
| Widget | Jetpack Glance widget showing today's total spend; tap to open the app. |
The app runs with zero configuration — the default exchange-rate provider (Frankfurter) is free and needs no API key.
Running on a physical device (dark theme) with the first-run seed data.
| Dashboard | Transactions | Add / Edit | Converter |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Home-screen widget — today's spend, live via Jetpack Glance (surrounding apps blurred for privacy):
Layered, with dependencies pointing downward only (ui → domain → data):
┌─────────────────────────────────────────────┐
│ ui Compose screens, ViewModels, │
│ navigation, theme, Glance widget │
├─────────────────────────────────────────────┤
│ domain Money value type, CurrencyConverter,
│ models, repository interfaces, │
│ use-cases (pure Kotlin) │
├─────────────────────────────────────────────┤
│ data Room (DAOs, entities), Retrofit, │
│ repositories, mappers, RatesCache │
└─────────────────────────────────────────────┘
- domain is pure Kotlin (no Android imports): the
Moneytype, theCurrencyConverter, domain models, repository interfaces, and use-cases. - data implements the repository interfaces with Room + Retrofit, exposes reads as
Flow, and wraps network calls in a sealedDataResult. - ui holds Compose screens and Hilt-injected
ViewModels exposing immutableStateFlow<UiState>(unidirectional data flow).
Single source of truth is the Room database — the UI always observes the DB via Flow; the
network layer only updates the cache.
See docs/architecture.md for the money-handling and cross-rate design notes (the most Revolut-relevant part).
Money is never a Double/Float. It's an integer count of a currency's minor units
plus an ISO-4217 code, with all arithmetic done via BigDecimal and banker's rounding
(HALF_EVEN). Decimal places are derived per-currency from
java.util.Currency.getDefaultFractionDigits() — JPY 0, USD 2, KWD 3 — never a hardcoded
"÷100". Conversion uses cross-rates against a single base (EUR):
result = amount × (rate[target] / rate[source])
Only the final result is rounded, to the target currency's precision. This logic was
test-driven first — see
CurrencyConverterTest.
Kotlin 2.0 · Jetpack Compose (Material 3, BOM) · MVVM + use-cases · Coroutines/Flow · Hilt ·
Retrofit + OkHttp · kotlinx.serialization · Room (Flow DAOs) · Navigation-Compose
(type-safe routes) · Jetpack Glance widget · DataStore · custom Compose-Canvas donut chart.
Testing: JUnit4, MockK, Truth, Turbine, kotlinx-coroutines-test, Robolectric, Room in-memory.
Coverage via Kover. Versions are managed in a
Gradle Version Catalog.
Default — Frankfurter (https://api.frankfurter.dev/): free,
keyless, ECB reference data. Because ECB publishes roughly once per business day, "live"
here means latest available — so a 24h cache is correct and expected, not a limitation.
The rate's date is shown in the UI so you know how fresh it is, and cached rates are served
when offline.
The provider sits behind the RatesRepository interface and a single BuildConfig.RATES_BASE_URL
constant, so swapping providers is a one-line change. Optional alternatives are documented in
docs/architecture.md; any that need an API key read it from
local.properties (git-ignored) via BuildConfig — no secrets are committed.
Requirements: JDK 17, Android SDK (compileSdk 35), minSdk 26.
./gradlew assembleDebug # build the debug APK
./gradlew testDebugUnitTest # JVM unit tests
./gradlew connectedAndroidTest # instrumented Room/DAO tests (device/emulator required)
./gradlew koverHtmlReport # coverage report -> app/build/reports/kover/html/index.htmlOr just open the project in Android Studio (Ladybug or newer) and Run.
The Gradle wrapper is checked in, so a local Gradle install is not required.
- Unit (JVM), test-first: the
CurrencyConverter(exhaustive — identity, base handling, cross-rate, JPY 0-dp, KWD 3-dp, HALF_EVEN boundary, unknown currency), theMoneytype, DTO⇄domain / entity⇄domain mappers, the aggregating use-cases, and every ViewModel (using in-memory fake repositories and an injected test dispatcher; Flow emissions driven withkotlinx-coroutines-test). - Instrumented: Room DAO tests against an in-memory database (insert/query/delete, date ranges, Flow emissions).
- Coverage is wired via Kover. The latest generated report shows 95.0% line coverage
for the target
domain+ ViewModel slice (398/419lines covered). Overall app line coverage is 44.9% because UI composables, navigation/theme/widget surfaces, and data/DAO plumbing are still included in the full report but are not the target metric.
Verified locally with JDK 17 and Android SDK:
./gradlew assembleDebug— passed./gradlew testDebugUnitTest— passed (45JVM unit tests)./gradlew koverHtmlReport/./gradlew koverXmlReport— passed./gradlew connectedDebugAndroidTest— built the test APK, then failed withNo connected devices!because no Android device/emulator was attached and no AVD images were installed in this SDK.
com.manasrao.spendlens
├── di/ Hilt modules (Database, Network, Repository, DataStore, Coroutines, Widget)
├── domain/ Money, CurrencyConverter, models, repository interfaces, use-cases
├── data/ Room (entities/DAOs/seed), Retrofit remote, mappers, repository impls
├── ui/ theme, common composables, navigation, dashboard/transactions/add/converter
└── widget/ Glance today-spend widget
- Rate base is EUR (ECB native) for all cross-rate math; the display currency is a separate, user-chosen preference persisted in DataStore.
- If a foreign-currency expense can't be converted because rates haven't loaded yet (offline first run), it's excluded from aggregation and the UI shows an "offline / rates may be stale" hint, rather than blocking the whole screen.
- Refunds/negative amounts are supported by the
Moneytype and converter, though the add form currently accepts positive amounts only. - Launcher icon and widget preview use vector/adaptive assets (no rasterized PNGs), which keeps
the repo binary-free;
minSdk 26makes adaptive-only icons sufficient.




