Skip to content

LLawliet188/SpendLens

Repository files navigation

SpendLens

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


What it does

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.


Screenshots

Running on a physical device (dark theme) with the first-run seed data.

Dashboard Transactions Add / Edit Converter
Dashboard — animated donut chart and category cards Transactions — grouped by day, original + converted amounts Add / edit transaction form Live currency converter, EUR to CAD

Home-screen widget — today's spend, live via Jetpack Glance (surrounding apps blurred for privacy):

SpendLens home-screen widget showing today's spend


Architecture

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 Money type, the CurrencyConverter, 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 sealed DataResult.
  • ui holds Compose screens and Hilt-injected ViewModels exposing immutable StateFlow<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 handling (the important bit)

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.


Tech stack

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.


Exchange-rate provider

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.


Build & run

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.html

Or 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.


Testing strategy & coverage

  • Unit (JVM), test-first: the CurrencyConverter (exhaustive — identity, base handling, cross-rate, JPY 0-dp, KWD 3-dp, HALF_EVEN boundary, unknown currency), the Money type, 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 with kotlinx-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/419 lines 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.

Build status in this environment

Verified locally with JDK 17 and Android SDK:

  • ./gradlew assembleDebugpassed
  • ./gradlew testDebugUnitTestpassed (45 JVM unit tests)
  • ./gradlew koverHtmlReport / ./gradlew koverXmlReportpassed
  • ./gradlew connectedDebugAndroidTest — built the test APK, then failed with No connected devices! because no Android device/emulator was attached and no AVD images were installed in this SDK.

Package structure

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

Assumptions & deviations

  • 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 Money type 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 26 makes adaptive-only icons sufficient.

About

Multi-currency personal-finance tracker for Android — Kotlin, Jetpack Compose, MVVM, Hilt, Room, live exchange rates. Test-driven money math (BigDecimal, per-currency precision).

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages