Three years ago, I needed a unit converter for a client project. "How hard could it be?" I thought. Multiply by some conversion factor, done.
Yeah, that didn't age well.
That "simple" project turned into an app with 54 conversion categories, 800+ units, 18 financial calculators, and sensor-based tools like a compass and sound meter. I basically scope-creeped myself for three years straight.
The First Version Was Embarrassing
My initial approach to unit conversion:
fun convertLength(value: Double, from: String, to: String): Double {
return when {
from == "meter" && to == "kilometer" -> value / 1000
from == "kilometer" && to == "meter" -> value * 1000
from == "meter" && to == "mile" -> value * 0.000621371
// ... 400 more combinations
else -> value
}
}
Works great for 5 units. Then I added more. And more. Suddenly I'm looking at 2,450 conversion cases for just 50 units. The complexity grows quadratically and I didn't think about that until I was already deep in it.
I rewrote the whole thing using what's called the base unit pattern. Each unit just knows how to convert to and from one base unit (meters for length, grams for weight, etc):
data class Unit(
val id: String,
val name: String,
val toBase: (Double) -> Double,
val fromBase: (Double) -> Double
)
fun convert(value: Double, from: Unit, to: Unit): Double {
val baseValue = from.toBase(value)
return to.fromBase(baseValue)
}
Now adding a unit is one line. Should've done this from day one.
Temperature Made Me Feel Stupid
I thought I had everything figured out. Then temperature broke my brain for an afternoon.
Celsius to Fahrenheit isn't just multiplication - there's an offset: (C × 9/5) + 32. I spent way too long trying to force it into my multiplication-only mental model before realizing my lambda approach already handled it:
Unit("fahrenheit", "Fahrenheit",
toBase = { (it - 32) * 5 / 9 }, // to Celsius
fromBase = { it * 9 / 5 + 32 } // from Celsius
)
Lesson learned: what looks simple usually isn't. Design for weird cases early.
Currency Was a Whole Different Problem
Static conversions? Easy. Currency rates change every day. Sometimes multiple times a day.
I needed to fetch fresh rates from an API, cache them for offline use, update them in the background, and somehow tell users when the rates are stale. Here's the basic idea:
class CurrencyRepository(
private val api: CurrencyApi,
private val cache: CurrencyCache
) {
suspend fun getRates(): List<Currency> {
return try {
val fresh = api.getLatestRates()
cache.save(fresh)
fresh
} catch (e: Exception) {
cache.get() ?: throw e
}
}
}
The cache fallback sounds obvious but I forgot it initially. Users on flights or in basements were getting crashes. Not great.
The HDFC Bank Incident
Users started requesting EMI and SIP calculators. Cool, I thought. Just plug in the formula:
EMI = P × r × (1 + r)^n / ((1 + r)^n - 1)
Implemented it. Tested it. Deployed it. Got emails saying my calculator was "wrong" because it didn't match HDFC Bank's website.
Turns out banks round EMI to the nearest rupee first, then recalculate everything based on that rounded number. Mathematically "correct" wasn't what users wanted - they wanted to match their bank statements.
fun calculateEMIBankStyle(principal: Double, rate: Double, months: Int): EMIResult {
val rawEMI = calculateEMI(principal, rate, months)
val roundedEMI = rawEMI.roundToLong().toDouble()
val totalPayable = roundedEMI * months
val totalInterest = totalPayable - principal
return EMIResult(roundedEMI, totalInterest, totalPayable)
}
Oh, and 0% interest promotional loans? Division by zero. That was a fun crash report to wake up to.
"Can You Add a Compass?"
People kept asking for random utility tools. Compass, bubble level, sound meter. I figured why not, it's just sensors.
It's not "just sensors."
Building a compass needs both accelerometer (which way is down) and magnetometer (which way is north). They call this sensor fusion. Raw data from these sensors jumps around like crazy - you need filtering:
private fun lowPassFilter(input: FloatArray, output: FloatArray) {
val alpha = 0.1f
for (i in input.indices) {
output[i] = output[i] + alpha * (input[i] - output[i])
}
}
The bubble level was easier - just accelerometer. But then I learned sensors drain battery fast. Forgot to unregister listeners when app went to background. Users complained about battery drain. Fixed it.
Sound meter needed microphone permission, real-time audio processing with AudioRecord, converting amplitude to decibels... and then I had to figure out what decibel ranges actually mean. Is 50dB quiet? Loud? I ended up just labeling ranges like "Quiet" and "Loud" because raw numbers meant nothing to most users.
What Actually Worked
Looking back after three years:
The base-unit pattern saved me so much time. Every new unit is just one line now. I've added hundreds without touching conversion logic.
Matching bank calculators instead of being "mathematically correct" stopped the complaint emails overnight.
Testing on real phones caught issues emulators never showed. Sensor behavior varies a lot between devices.
What I'd Change
If I rebuilt this from scratch:
I'd use a category interface instead of sealed classes. Adding new category types is more annoying than it should be.
I'd separate display formatting from domain logic earlier. Right now it's messier than I'd like.
I'd write roundtrip tests for conversions from day one. Convert X to Y to X should give you X back. Found some precision bugs way too late.
Three Years Later
What started as a weekend project became something I actually use daily. It handles the boring conversions (length, weight) and the interesting stuff (currency, EMI, compass) without needing five different apps.
If you're building something similar, start with the base-unit pattern. Add complexity when users ask for it, not before. Most of what I learned came from shipping, getting complaints, and fixing things.