Four 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 74 conversion categories, 40 utility tools, and 20 financial calculators. I basically scope-creeped myself for four 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.
The Number Format Rabbit Hole
Once basic conversions worked, I thought I was done with the hard parts. Then an Indian user reported that "1,00,000" was showing as "100,000." Both are correct — India uses the lakh system (1,00,000) while most other countries use the international format (100,000).
I'd hardcoded NumberFormat.getInstance(Locale.US) everywhere. Fixing it meant:
fun formatNumber(value: Double, locale: Locale): String {
return when {
locale == Locale("hi", "IN") -> formatIndianStyle(value)
else -> NumberFormat.getInstance(locale).format(value)
}
}
Except it wasn't just India. Different countries have different decimal separators too. Germany uses comma for decimals (3,14) while the US uses period (3.14). I had users entering "3,5" meaning three and a half, and my parser was reading it as thirty-five hundred.
I ended up building a locale-aware input parser that checks the device language settings. It works about 90% of the time. The other 10% is people with their phone set to English but expecting Indian number formats. I added a manual format selector in settings for those cases.
Then there was scientific notation. Engineers converting between very large and very small numbers (nanometers to kilometers, for example) would get results like 0.000000001 — which is both correct and unreadable. I added automatic scientific notation switching above 10 digits, with a toggle to force it on or off. Small feature, but it took three attempts to get the threshold right.
From 5 Categories to 74
Here's what nobody warns you about scope creep: it doesn't happen overnight. It happens one "quick addition" at a time.
First it was just length, weight, temperature. Then users asked for speed. Then pressure. Then fuel economy. Then cooking measurements, shoe sizes, data storage, screen resolution, blood sugar, typography units...
At some point I stopped saying no. Every category felt like "just one more." I woke up one morning and counted 74 categories in the app. Seventy-four. I had categories I didn't even remember adding.
The weirdest request I actually built? A shoe size converter. US, UK, EU, Japan, Korea — they all have completely different numbering systems with no consistent mathematical relationship between them. I ended up using lookup tables instead of formulas. Sometimes brute force is the right answer.
The base unit pattern saved me here. Every new category was the same structure — define the units, pick a base, write the lambdas. No new architecture needed. But the UI was a different story. How do you let users navigate 74 categories without it feeling like scrolling through a phone book?
I ended up building a favorites system and a search bar. Should've added those at category 10, not category 50. By the time I built search, half my reviews were complaining about finding things.
Currency Was a Whole Different Problem
Static conversions? Easy. Currency rates change every day. Sometimes multiple times a day.
Every other conversion in the app is static — a meter is always 3.28084 feet. But currency is live data, and that changes everything. You need to think about caching for offline use, showing users when rates are stale, and handling failures gracefully when there's no network.
The first version just crashed when there was no internet. Users on flights or in basements would open the currency converter and get a blank screen. Took me an embarrassing amount of time to add proper offline handling with cached rates and a "last updated" timestamp.
Then there were the edge cases nobody thinks about. Some currency pairs just don't have reliable exchange rate data. Try finding a live rate for the Bhutanese Ngultrum against the Argentine Peso. My app crashed on those because I assumed every pair would have data. It didn't.
The biggest lesson from currency: live data is a completely different engineering problem than static data. The conversion math is trivial. Everything around it — freshness, caching, error states, rate limits — is where the real complexity lives. If I'd known that upfront, I probably would've skipped currency entirely in version 1. But users expect it, and "unit converter without currency" feels incomplete.
Financial Calculators Broke My Assumptions
Users started requesting EMI and loan calculators. I figured it was straightforward math:
EMI = P × r × (1 + r)^n / ((1 + r)^n - 1)
Implemented it, tested it, deployed it. Got emails saying my calculator was "wrong."
The problem? Floating point precision. When you're calculating a 20-year loan with monthly compounding, tiny rounding errors in intermediate steps accumulate into visible differences in the final number. My result would show ₹43,391.47 while users expected ₹43,392 because that's what their bank statement said.
Banks round the EMI to the nearest whole number first, then recalculate everything based on that rounded figure. Mathematically "correct" and "matches your bank" are two different things.
fun calculateEMI(principal: Double, annualRate: Double, months: Int): Double {
val monthlyRate = annualRate / 12 / 100
val emi = principal * monthlyRate *
Math.pow(1 + monthlyRate, months.toDouble()) /
(Math.pow(1 + monthlyRate, months.toDouble()) - 1)
return Math.round(emi).toDouble()
}
And then there was the 0% interest promotional loan. Division by zero. That was a fun crash report to wake up to on a Monday morning. I had to special-case it — when rate is zero, EMI is just principal divided by months. Took me 2 minutes to fix, but it was live for 3 weeks before someone hit it.
I went from 0 financial calculators to 20. EMI, SIP, mortgage, compound interest, ROI, depreciation, retirement planning, GST, salary calculator... each one had its own set of edge cases I only discovered after shipping. Compound interest with different compounding frequencies (daily vs monthly vs quarterly) was another rabbit hole. The SIP calculator needed to handle step-up investments where users increase their monthly amount by a percentage each year. None of this was in the original "just add EMI" plan.
Building 40 Tools I Never Planned For
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."
The Compass Saga
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 alpha value was trial and error. Too low and the compass barely moves. Too high and it jitters like it's having a seizure. I tested on 4 different phones and each one needed different tuning. Ended up picking 0.1 as a compromise that works "okay" everywhere but "perfect" nowhere.
Sound Meter Reality Check
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 labeling ranges like "Quiet Library," "Normal Conversation," "Busy Traffic" because raw numbers meant nothing to most users.
The calibration problem was worse. Every phone's microphone has different sensitivity. My Pixel showed 45dB in a quiet room while a Samsung showed 52dB for the same room. I couldn't calibrate for every device, so I added a disclaimer. Not elegant, but honest.
BMI Calculator: Simpler Than Expected, Until It Wasn't
BMI is just weight divided by height squared. Done in 5 minutes. Then I learned that BMI categories differ by region — the WHO has one set of ranges, but several Asian countries use lower thresholds because health risks start at lower BMI values for certain populations.
I added a region selector. Then users wanted BMR (Basal Metabolic Rate), body fat percentage, calorie calculators, water intake recommendations. Each "simple" health calculator pulled in another one. The body fat calculator alone has different formulas for men and women, and the Navy method needs neck circumference which nobody expects to measure.
The water intake calculator was the one that surprised me most. It's not just "drink 8 glasses a day." It depends on weight, activity level, climate, and altitude. I spent a weekend reading hydration research papers to find a reasonable formula. Ended up using a weight-based calculation with activity multipliers:
fun dailyWaterIntake(weightKg: Double, activityLevel: ActivityLevel): Double {
val base = weightKg * 0.033 // liters
return when (activityLevel) {
ActivityLevel.SEDENTARY -> base
ActivityLevel.MODERATE -> base * 1.2
ActivityLevel.ACTIVE -> base * 1.4
ActivityLevel.VERY_ACTIVE -> base * 1.6
}
}
Is it medically accurate? Probably not perfectly. But it's better than a fixed number, and I put a disclaimer that it's an estimate, not medical advice. Every health calculator in the app has that disclaimer now. I'm not a doctor and I don't want users treating my app like one.
Password Generator: Random Isn't Random
I thought kotlin.random.Random was fine for generating passwords. A security-minded user pointed out that it's a pseudorandom number generator — predictable if you know the seed. For a password generator, that matters.
Switched to java.security.SecureRandom. Then I had to handle the UX of password requirements — minimum length, uppercase, lowercase, numbers, symbols. Users wanted to exclude ambiguous characters (0 vs O, 1 vs l). Each toggle was a small feature that added real complexity to what I thought was a "generate random string" problem.
The copy-to-clipboard feature for passwords had its own bug. On some Android versions, the clipboard manager broadcasts the copied content to other apps. So I was essentially broadcasting generated passwords. Fixed it by using ClipData with the isSensitive flag on Android 13+ and clearing the clipboard after 30 seconds on older versions. Security is a rabbit hole with no bottom. Every time I think I've covered the edge cases, someone finds a new one. But for a utility app, "good enough" security is better than shipping without a password generator at all.
The Offline-First Decision
Around the time I had 30+ tools, I made a decision that saved me later: everything works offline except currency rates.
This wasn't noble engineering foresight. It was because my API server went down for 6 hours and I got 200+ one-star reviews that day. I spent the next two weeks removing every unnecessary network dependency. Calculators don't need internet. Sensor tools don't need internet. Unit conversion factors don't change (except currency). So I baked everything into the APK.
The trade-off is a slightly larger app size. But users in rural areas, on flights, or in countries with expensive data plans don't care about 2 extra MB. They care about the app working when they open it.
The ironic part is that going offline-first actually simplified the codebase. No network error handling for 90% of features. No loading spinners. No retry logic. The app opens, it works. I should've designed it this way from the start instead of backing into it after an outage.
Battery Drain: The Silent Killer
Sensors drain battery fast. I forgot to unregister listeners when the app went to background. Users complained about battery drain. Here's what the fix looked like:
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(this)
}
override fun onResume() {
super.onResume()
sensorManager.registerListener(
this,
accelerometer,
SensorManager.SENSOR_DELAY_UI
)
}
Basic lifecycle stuff. I knew this. I just forgot it in 3 out of 7 sensor-based tools because I was copying code between them and missed the cleanup. This is why I eventually created a base class for sensor tools — centralize the registration and unregistration so I can't forget it.
abstract class BaseSensorTool : Fragment(), SensorEventListener {
abstract val sensorType: Int
override fun onResume() {
super.onResume()
val sensor = sensorManager.getDefaultSensor(sensorType)
sensor?.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
}
}
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(this)
}
}
Every sensor tool extends this now. Compass, bubble level, metal detector, speedometer — they all get automatic cleanup. No more battery drain bugs from forgotten listeners.
Testing Across 74 Categories
Here's a problem I didn't anticipate: how do you test 74 conversion categories with hundreds of units each?
I couldn't manually verify every conversion. So I wrote roundtrip tests — convert a value from unit A to unit B, then back to unit A, and check if you get the original value within a tolerance:
@Test
fun testRoundtripConversion() {
val categories = ConversionRegistry.allCategories()
for (category in categories) {
val units = category.units
for (from in units) {
for (to in units) {
val original = 42.0
val converted = convert(original, from, to)
val backConverted = convert(converted, to, from)
assertEquals(original, backConverted, 0.0001,
"Roundtrip failed: ${from.name} -> ${to.name}")
}
}
}
}
This single test caught 11 bugs the first time I ran it. Mostly precision issues — conversions where the toBase and fromBase lambdas weren't exact inverses of each other. One was a flat-out wrong conversion factor for nautical miles that had been live for months. Nobody noticed because apparently not many people convert nautical miles on their phone. But still.
The test runs about 180,000 conversion pairs now. Takes 8 seconds. Worth every millisecond.
The Play Store Listing Problem
Building the app was one challenge. Getting people to find it was another.
"Unit Converter" is one of the most competitive search terms on the Play Store. There are literally thousands of unit converter apps. My first listing was just "Unit Converter" with a generic description. It was buried on page 15 of search results.
What helped was getting specific. Instead of competing on "unit converter," I optimized for long-tail searches — "EMI calculator India," "cooking measurement converter," "offline compass app." These had less competition and brought in users who actually needed specific features.
The other thing that moved the needle was responding to every review. Good reviews, bad reviews, feature requests — I replied to all of them. Google seems to factor engagement into ranking, and users who got a reply often updated their rating. A one-star "doesn't have BMI calculator" turned into a four-star "developer added BMI calculator after I asked" two weeks later.
Screenshots mattered more than I expected too. My first listing had generic screenshots showing the home screen. When I switched to screenshots showing actual conversions in action — a currency conversion, an EMI calculation, the compass pointing north — the install conversion rate went up noticeably. People want to see the app doing something useful, not just existing.
What Users Actually Use
After four years of analytics, here's what surprised me: the most-used features aren't the ones I spent the most time on.
The top 5 by daily active usage are length, weight, currency, temperature, and the EMI calculator. Basic stuff. The fancy sensor tools I spent weeks building? They account for maybe 8% of total usage. The compass gets opened when someone's hiking or lost in a new city. The sound meter gets opened once out of curiosity and then forgotten.
But here's the thing — those niche features drive installs. People search for "offline compass app" or "BMI calculator" and find my app. Then they stick around for the unit conversions. The utility tools are marketing. The conversions are retention.
I wouldn't have known this without analytics. For the first year I was building features based on gut feeling and email requests. The loudest users aren't representative of the majority. One person emailing three times about a feature doesn't mean a million users want it.
What I'd Change If I Started Over
If I rebuilt this from scratch:
Use a category interface instead of sealed classes. Adding new category types is more annoying than it should be. Every new category requires touching the sealed class definition and the when-expression that handles it. An interface with a registry pattern would let me drop in new categories without modifying existing code.
Separate display formatting from domain logic earlier. Right now, number formatting (decimal places, thousand separators, scientific notation) is tangled up with conversion logic in ways that make me wince. I've been untangling it slowly, but it's the kind of mess that's easier to prevent than to fix.
Write roundtrip tests from day one. Convert X to Y to X should give you X back (within floating point tolerance). I found precision bugs way too late — some conversions were losing accuracy after the 6th decimal place. For most users that doesn't matter, but for engineers converting between imperial and metric tolerances, it absolutely does.
Build the favorites and search UI before category 10. Half my early negative reviews were about navigation. The features were there, users just couldn't find them.
Don't add every feature users ask for. Some requests make the app better. Some just make it bigger. I should've said no to a few of those 74 categories. Does anyone really need a troy ounce to pennyweight converter? Apparently someone does, because they emailed me twice about it.
Handle localization from the start. I added 11 language translations late in the project and it was painful. Every hardcoded string in the app — and there were hundreds — had to be extracted to resource files. The conversion category names, unit names, error messages, button labels, tooltips... all of it. If I'd used string resources from day one, this would've been a non-issue.
The Numbers Game
Sometimes I look at the raw numbers and wonder how it got this big:
- 74 conversion categories
- 40 utility tools (sensors, health, security, productivity)
- 20 financial calculators
- 11 supported languages
- Roughly 1,200 individual units across all categories
Each unit has a name, abbreviation, conversion lambda, and display format. Each tool has its own UI, input validation, and edge cases. Each financial calculator has its own formula with its own gotchas.
The codebase isn't elegant. There are parts I'm embarrassed to show. But it works, it's fast, and it gives correct answers. At some point I stopped optimizing for clean code and started optimizing for "does this help the user right now."
Four Years Later
What started as a weekend project became something I actually use daily. 74 categories, 40 utility tools, 20 financial calculators — and I'm still getting feature requests.
The biggest lesson isn't technical. It's that users don't care about your architecture. They care about whether the app gives them the right answer, fast, without draining their battery or needing internet. Everything else is my problem, not theirs.
The app isn't done. I don't think it ever will be. Last week someone emailed asking for a fuel cost calculator that factors in current gas prices by location. I said no. Then I thought about it for two days. I'll probably build it next month.
That's how all 74 categories happened. One "no" at a time, slowly turning into "fine, I'll add it."
If you're building something similar, start with the base-unit pattern. Add complexity when users ask for it, not before. Test your conversions with roundtrip tests early. Go offline-first if you can. And for the love of all that is good, remember to unregister your sensor listeners.