Building Volt: an offline electronics calculator with zero permissions
How I kept every calculation in pure, tested functions, found standard resistor pairs without trying every combination, and chased down a white flash in React Native's navigation.
Rayhan M. Project: Sep 2026 Written 5 min read Source on GitHub Get it on Google Play
Volt is a calculator for people standing at a breadboard: resistor colour codes, Ohm’s law, voltage dividers, LED series resistors and standard-value lookup. It runs fully offline, asks for no permissions, and stores exactly one thing on the device: whether you prefer the light or dark theme.
It’s built with React Native, Expo SDK 57 and TypeScript in strict mode, and it’s live on Google Play. The source is on GitHub.
The rule that shaped everything
Every calculation lives in src/lib as a pure function: numbers in, a typed result out, or an Error with a message a person can read. Nothing in src/lib imports React.
export function calculateLedSeriesResistor(
supplyVoltage: number,
forwardVoltage: number,
forwardCurrent: number,
series: ESeriesName = "E24",
): LedResistorResult
Screens only turn keypad input into numbers, call one of these functions, and render either the result or error.message. I wrote and tested the whole calculation layer before building a single screen. That meant I could rework the UI as often as I wanted without ever wondering whether a result had quietly changed.
Ohm’s law is mostly edge cases
solveOhmsLaw takes exactly two of voltage, current, resistance and power and returns all four. Six possible input pairs, each with its own formula, is the easy part. The interesting part is deciding what counts as valid physics:
- Voltage and current can be negative (reverse polarity), but anything implying negative resistance or negative power throws.
- A known current with zero voltage is a valid short circuit: R = 0, P = 0.
- Zero resistance with a non-zero voltage would need infinite current, so it throws.
- Zero voltage and zero power together say nothing about the current, so that throws too.
- Given only R and P, the sign of V and I can’t be recovered from magnitudes. The function returns the non-negative root and the doc comment says so.
Of the 75 unit tests in the project, 27 are for this one function.
Finding standard resistor pairs without trying them all
The voltage divider’s reverse mode answers: “I have 12 V and need 3.3 V. Which two resistors I can actually buy get me closest?”
The obvious approach tries every R1 with every R2 from the standard series, which is n² pairs. But rearranging the divider equation shows that for any R1 there is exactly one ideal R2:
Vout / Vin = R2 / (R1 + R2) → R2 = R1 × ratio / (1 − ratio)
So the search walks R1 through the series once, computes the ideal R2, snaps it to the nearest standard value, and ranks by the resulting error:
const suggestions = standardValuesInRange(series, range).map((r1) => {
const idealR2 = (r1 * ratio) / (1 - ratio);
const r2 = findNearestStandardValues(idealR2, series, range).nearest;
const vout = voltageDividerOutput(vin, r1, r2);
return { r1, r2, vout, errorPercent: ((vout - targetVout) / targetVout) * 100 };
});
That’s n candidate pairs instead of n². Each nearest-value lookup is still a linear scan, which is fine at a few hundred values per series; precomputing the list and binary searching it is the obvious next step if the range grows. The screen runs this over E12, E24 and E96, merges the results so each pair keeps the easiest-to-buy series label, and shows the ten best.
E-series values: copy some, generate others
E12 and E24 are copied from the IEC 60063 tables, because the historical values aren’t pure logarithmic rounding. E96 is generated from its definition, 10^(n/96) rounded to three significant figures, which is less error-prone than typing 96 numbers.
Scaling base values across decades exposed a classic floating-point problem: in JavaScript, 1.1 * 100 is 110.00000000000001. Every scaled value is rounded back to 12 significant figures before it’s compared or displayed.
A keypad instead of the keyboard
Every tool screen has the same four zones: header, Calculated, Entered, and a 12-key keypad that is always on screen. I replaced the system keyboard on purpose. It slides in over the fields you’re editing, its layout varies between Android manufacturers, and its keys aren’t sized for a thumb at a bench. A fixed keypad means the layout never moves.
Nothing you type ever appears in the Calculated zone. Input and output are separated by position first and colour second, so the screen still reads correctly in greyscale.
The white flash
In dark mode, moving between screens flashed white for a frame. Every screen’s own background was correctly dark, so the flash had to be coming from something behind them.
It was. Expo Router mounts React Navigation’s container, and with no theme passed in it falls back to React Navigation’s default light theme. That theme’s white background is what the native screen stack paints between screens during a transition. The fix was to build the navigation theme from Volt’s own tokens, so the layer underneath matches the active theme at the source.
A second, smaller flash appeared when toggling the theme on Android. Native backgrounds (the stack surface, and the root view set through expo-system-ui) switched the instant the toggle was pressed, while the visible content was still running its 200 ms crossfade. A useSettledThemeName hook now holds those native layers on the old colour until the crossfade finishes.
The theme itself is a tiny module-level store read with React’s useSyncExternalStore, rather than a Context provider or a state library:
export function useThemeName(): ThemeName {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
Zero permissions, on purpose
Volt has no networking code, but Expo can add permissions by default, so app.json blocks them explicitly:
"blockedPermissions": [
"android.permission.INTERNET",
"android.permission.SYSTEM_ALERT_WINDOW",
"android.permission.VIBRATE",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
]
Two small Expo config plugins cover gaps in the tooling: one enables R8 minification for release builds, and one adds the Android 12+ splash branding image that expo-splash-screen has no option for.
Testing
75 Jest tests cover the five calculation modules, using values I can check by hand: brown-black-red-gold is 1 kΩ ±5%, and a 5 V supply driving a 2 V LED at 20 mA needs 150 Ω and dissipates 0.06 W. A round-trip test encodes a resistance to colour bands and decodes it back. Screens were tested by hand on Android; component tests for the Ohm’s law input hand-over are the next thing I’d add.
How it was built
I used AI as a coding assistant. I wrote the product and design specs, made the scope and architecture calls, and reviewed and tested every change. The technical report goes deeper on each part.
- #react-native
- #typescript
- #android
- #testing