All projects
Live · web + APKProject 09
Exchange calculatorAndroid · Web · WindowsMulti-rate

Venekambio

One figure, six currencies, zero servers. No login, no accounts, no backend: rates are fetched from the phone itself and everything saved stays on it. $0/month to operate.

FlutterRiverpodNuxt 4 SSGTypeScriptVercel (estático)Windows

One amount, six currencies, zero servers.

At a Venezuelan register the official BCV rate, USDT, the euro, the Monitor rate and bolívares all coexist. Venekambio shows them all at once from a single keystroke, and does it without a backend: the rates are read from the phone itself.

Base currency
You type one amount
250 USDT
USDT · no screen change, no second tap
BSbase
Bs240,875.00
BCVofficial
$281.07
EURofficial
€246.57
MONparallel
$249.87
ETHP2P
Ξ0.0936
Average rates exactly as the sources returned them on 28 Sep 2026. Switch the base: all six results come out of the same function.
$0/monthto operate: no backend, no database, no accounts
6currencies per amount: Bs, BCV, EUR, MON, USDT, ETH
3surfaces: Android, web and a launcher for Windows
01 · How it works

From opening the app to six results without touching a server

Tap the steps or let it play. Each step lights up the piece it describes.

Open the app
no login, no steps
4 sources
in parallel · 5 s
Snapshot
immutable
convert()
from → Bs → to
6 currencies
hero + 9 chips
Step 1 of 5 · plays on its own
The first screen is already the calculator
No account, no onboarding, no form. You open it and see the numeric keypad under the display. Everything the app remembers (base, history, Pago Móvil details) lives on the phone itself.

The complete conversion. It is the same function in the app, on the web and in the Windows launcher.

// web/composables/useConvert.ts
if (from === to) return value
const bs = from === 'BS' ? value : value * (rates[from] ?? 0)
if (to === 'BS') return bs
const target = rates[to]
if (!target || target === 0) return 0
return bs / target
02 · Architecture

What is there and what is not

Three clients, four public sources and state on the device. The slot for a server of our own is empty on purpose.

No backend
Public sources
ve.dolarapi.com
BCV · EUR · Monitor
2 requests
criptoya.com
USDT · ETH (Binance P2P)
2 requests
4 HTTPS requests in parallel · 5 s timeout · no key
Client
Android
Flutter · Riverpod
Web
Nuxt 4 SSG · TypeScript
Windows
Flutter · quick-launcher
everything that is saved stays here
Device
Phone prefs
SharedPreferences · history (20) · settings · Pago Móvil
localStorage
chip order and visibility
Own server
does not exist
$0/month

Delivery is static too: GitHub builds the web on Vercel, and the APK and the Windows installer are two more files under /downloads on the same domain.

03 · An end-to-end example

A kilo of cheese, four rates and a customer waiting

A made-up case with the real rates from 28 Sep 2026, to see what each person looks at before talking about decisions.

1
07:40 · market · example
Carmen opens the app
She sells cheese. She buys stock in USDT and gets paid in bolívares. She opens Venekambio: four requests go out at once and the hero shows the green dot with Updated 07:40. Her saved base is USDT.
2
4.20 USDT
She types what the kilo cost her
Without changing screens she sees Bs 4,046.70, $ 4.72 BCV, € 4.14 and $ 4.20 MON. She decides to charge Bs 4,500 per kilo.
3
the customer
Looks at a different rate
The customer compares mentally against the BCV, the reference they see at the supermarket: Bs 4,500 is $ 5.25 BCV. Carmen, on the other hand, knows it is 4.67 USDT, and that she keeps 0.47 USDT per kilo. Both figures come from the same screen.
4
Pago Móvil
Gets paid without dictating four details
Bank, ID number, phone and name are saved on the phone. She shows them, the customer transfers. The operation lands in the local history, which keeps the last 20.
5
next Monday
The BCV rate does not change even though the BCV already published another
It is a banking Monday. The app keeps showing Friday's rate in force, because it reads the rate in force and not the last one published. Why that matters, in challenge 02.
04 · The challenges

Five times the obvious answer was wrong

Each challenge follows the same pattern: what anyone would do first, why it does not work, what was done and the number that proves it. The technical detail stays folded.

01

A conversion is not one calculation, it is four

The obvious approach
Do what every exchange calculator does: pick A, pick B, type, see a number.
Why it fails
At a Venezuelan register nobody needs one conversion. The merchant buys in USDT and sells in BCV; the customer pays in bolívares and compares against BCV; if they have savings in USDT they work out how much to exchange. With A → B that is three or four repetitions of the same amount, with the queue waiting.
A → B calculator · at the register
250 · USDT → Bs
250 · USDT → BCV
250 · USDT → EUR
The same amount three times, three currency switches, with the customer waiting.
Venekambio
250 · base USDT
One input. Bs, BCV, EUR, MON and ETH already computed on the same screen.
What was done
The calculator does not produce a result, it produces a snapshot. On each keystroke the conversion runs against all six currencies from the same immutable RatesSnapshot: a hero for the base and a strip of chips with the others already computed. No scroll, no tabs, no second tap.
6 → 1six results per keystroke. Computing six instead of one costs nothing; the expensive part is the fetch, and it happens once per session.
Technical detail

In Dart: visibleCodes.map((code) => convert(value, baseCode, code, snap)). In TypeScript, the same line over snapshot.asRateMap(). The function is pure and triangulates through Bs, so the order of the currencies or how many are visible changes nothing.

The cost is that the screen shows more numbers than any competitor. It is offset with hierarchy (one big hero, small chips) and with the option to hide and reorder chips, which is saved on the device.

02

Today's rate is not the last one the BCV published

The obvious approach
Take the last number that shows up in the source. It is the most recent, so it is today's.
Why it fails
Every afternoon the BCV publishes the rate with the next banking day as its value date. And on banking Mondays the banks are closed: on Friday the Tuesday rate is already published, skipping Monday. The most recent row on Monday 14 September said 842.2067 and applied on Tuesday; the rate in force that Monday was still 832.4883, Friday's.
Fri 11 Sep
832.4883
Rate with value date 11. On Friday afternoon the BCV already publishes the next one.
Mon 14 Sep · banking holiday
832.4883
Banks are closed. Friday's rate is still in force. No row exists for this day.
Tue 15 Sep
842.2067
The rate published on Friday carries a Tuesday value date: it skips Monday.
Read the last published row
842.2067 ✗
That Monday you would charge a rate that is not in force yet.
Read the rate in force
832.4883 ✓
The one the BCV has in force today, even if tomorrow's is already published.
What was done
The app reads the rate in force endpoint (/v1/cotizaciones), which filters by value date, and never the last row of the history. The value date travels in Rate.timestamp; the time the user sees in Updated 07:40 is the read time, not the BCV's. It was verified against the BCV's page on a banking Monday.
+1.17%error from reading the wrong row that Monday (842.2067 against 832.4883). On a Bs 50,000 sale that is 0.69 BCV dollars overcharged.
Technical detail

What you need to know about ve.dolarapi.com: fechaActualizacion is not when the data was updated, it is the BCV's value date. The rate-in-force route filters date ≤ today; the history route does not filter and exposes the row with a future date. On 28 Sep 2026, a Monday, the history already carried the row for Tuesday 29 (857.8876) while the rate in force was 857.0058.

There is no endpoint for "tomorrow's rate". The only way to see it is that leak in the history, and on a banking Monday no row exists for that day: the history returns 404.

The app does not show the value date to the user today, only the read time. A merchant who needs to know whether "tomorrow's" is already out has to go to the BCV's page.
03

Four sources, none with an SLA, no server of our own

The obvious approach
A backend that aggregates the four sources, normalizes them, caches them and exposes a stable endpoint.
Why it fails
That backend is a 24/7 server, a pipeline, a database and a monthly bill. For a product whose audience is the one least able to pay for it, the bill forces monetization, and monetization adds the friction we wanted to remove. And it does not fix the underlying problem: the sources are still the same.
ve.dolarapi.com200 · ok
/v1/cotizaciones
Official BCV and EUR
ve.dolarapi.com200 · ok
/v1/dolares/paralelo
Monitor dollar
criptoya.com200 · ok
/api/binancep2p/USDT/VES/1
USDT P2P · buy and sell
criptoya.comtimeout 5 s
/api/binancep2p/ETH/VES/1
ETH → 0 · its chip goes without a rate, the other five carry on
Future.wait in Dart, Promise.allSettled in TypeScript: each request returns its result or null, and never takes the others down.
What was done
The client consumes the four sources directly, in parallel, on every launch. Each request is wrapped in a _safeGet with a 5 s timeout that returns null if it fails. The currency that did not arrive is 0: the conversion returns 0 and its chip goes dark instead of taking the screen down. Without a server there is no single point of failure and nobody watching what each user looks up.
4 of 4independent requests. If ETH goes down, BCV, EUR, Monitor and USDT still arrive. No automatic retries: a long retry is worse than a visible 0.
Technical detail

The HTTP wrapper has no interceptors or retries; it catches ClientException, TimeoutException and FormatException and returns null. The repository is injected through Riverpod, so tests swap it for a FakeRatesRepository with no network.

Deliberate debt: the last snapshot is not saved. If the first launch has no network, there are no rates until it comes back. It is in the retrospective.
04

A rate is not always a number

The obvious approach
Model the rate as a double: bolívares per unit, done.
Why it fails
The BCV publishes a decree: one number with no spread. USDT and ETH are not a number, they are an order book with real buy and sell sides, and that difference is information for whoever is about to trade. A single field forces a choice: flatten the P2P spread into an average, or invent a phantom spread for the BCV. One loses information; the other lies.
BCV · official
857.0058
One decree, one number. There is no spread to show: buy = sell = avg.
USDT · Binance P2P
Buy
963
Sell
964
Avg
963.50
An order book: the buyer and the seller see different numbers. Three chips in the app.
What was done
A single Rate type with three sides (buy, sell, avg) and a separate field, bsPerUnit, as the canonical value for converting. For official sources the three sides are the same number. For P2P the real buy and sell are stored and the average is computed; if one leg arrives as 0 the other is used so zeros do not propagate.
9 chipsfor 6 currencies: USDT and ETH expose buy, sell and average; BCV, EUR and Monitor a single value. Each chip can be hidden.
Technical detail

Bs as the pivot avoids the pair matrix: six currencies would be 30 conversions; with one value per currency against Bs, six are enough. The model is identical in web/types/rate.ts and app/lib/domain/entities/rate.dart, with the currency catalog aligned by hand between the two.

history7d exists in the model but none of the sources publishes history: today it is seven copies of the average. It is in the retrospective.

05

Designing for someone who never installed an app

The obvious approach
A three-screen onboarding, sign-up with email and a tutorial on first launch.
Why it fails
The user is a market merchant, often with prior resistance to anything digital and little patience for long text. Every step before the first calculation is a reason to close the app and not come back. And there is a second user, the older adult, for whom a small number is a number that does not exist.
display_number.dart · scaleFor(formatted)
4,046.70≤ 9 characters · 100%
1,240,875.50> 9 characters · 82%
12,573,545.22> 12 characters · 70%, the floor
What was done
No login, no accounts, no onboarding: the first screen is the calculator with the rates loading. A big numeric keypad instead of a text field. The hero number starts at 64 px and steps down (82 % and 70 % as the floor) instead of truncating. Optional Large mode. Pago Móvil details are saved without validations that interrupt.
44 pxminimum for each key, 52 px in Large mode. Base palette (ink #1A1613 on #FAF6F0) chosen to pass AA contrast before drawing a single component.
Technical detail

On the web the hero uses clamp(38px, 4.5vw, 52px) and the same scale by length. The landing demo accepts a physical keyboard and scrolls to the display on touch.

The cost is an app with fewer visible features than a competitor: no charts, no alerts, no profile. Every new feature is one more surface to learn, and that user is not going to learn it.

"At the register, in front of the customer, the flow does not survive real friction."
Every product decision goes through the same question: does it help a merchant who needs to see four rates in two seconds? If the answer is not direct, the decision is badly framed.
05 · Decisions

A single filter: does this require a monthly service?

If the answer was yes and there was a reasonable alternative, the alternative was chosen. What follows is each decision with its cost.

WHY
Why no backend?
Because every service is a bill, and a bill forces you to charge the people least able to pay. The sources are public and keyless, the conversion is five lines and the state fits on the phone. A server would only add latency, a single point of failure and a place where people's lookups are visible.
Go deeper

What is lost: a cache shared between users, a rate history of our own and the ability to switch sources without shipping a new version. The first does not matter at this scale; the second and third are the price of $0/month and are accepted knowingly.

The whole stack · $0/month
Backend
$0 · none
All 4 sources are read from the client, on every launch.
Web hosting
$0 · Vercel · static
Nuxt 4 SSG: prerendered HTML, automatic deploy from GitHub.
Database
$0 · on the device
SharedPreferences in Flutter, localStorage on the web. History of 20 operations.
Rates
$0 · public APIs
dolarapi.com and criptoya.com, no key, no account.
Distribution
$0 · static files
51 MB APK and 11 MB Windows installer under /downloads on the same domain.
Accounts
$0 · none
No login, no profiles. Nothing to protect because nothing is stored off the device.
Analytics
$0 · GA4 · anonymous
Web only, in production, DNT respected and no IP.
The only recurring cost is the domain, about $10 a year. Everything else lives on free tiers with no ceiling that matters at the product's current size.
WHY
Why Flutter and not React Native or native?
Two native platforms are two release cycles for a single person. React Native was viable, but the JS bridge shows on budget phones, which are exactly the audience's, and the interface depends on fine control over typography and scales. Flutter compiles to native and every widget is its own.
Go deeper

The cost is weight: a 51 MB APK, more than its native equivalent. It comes from the embedded Flutter engine, it is fixed and does not grow with features. For an APK downloaded from the web it is something to watch, not a blocker.

The same decision served the third surface: the Windows launcher is Flutter with window_manager, hotkey_manager and tray_manager. It is invoked with Ctrl + Alt + Space, evaluates full expressions (1.250 × 3 + 980) with precedence and parentheses, and lives in the tray.

On Windows the translucent effects (acrylic, Mica) misaligned the render surface relative to the frameless window. It was tested with a normal window to rule out the layout, and the panel was left opaque. It is not a widget bug: it is the compositor.
WHY
Why Nuxt 4 SSG and not a SPA or SSR?
The web has to be findable ("BCV calculator", "Bs USDT exchange") and load fast on unstable networks. A SPA serves empty HTML and leaves SEO to the luck of JavaScript. SSR requires a 24/7 server. SSG prerenders at build, is served as static files and leaves the calculator reactive on the client.
Go deeper

Flutter Web for the landing was ruled out for the same reason: it paints on a canvas and crawlers see an empty page. Marketing content only changes on rebuild; for a page that changes monthly, not daily, that is an acceptable cost with automatic deploys from main.

Details that do matter on a website with a new domain: canonical on www, OG image as PNG (scrapers do not read SVG reliably), static robots and sitemap, favicon generated from the app icon so both surfaces share one identity.

WHY
Why three copies of the conversion and no shared package?
Because they are five pure lines. A package between Dart and TypeScript would require a pipeline just to keep them in sync, and the Windows launcher copies the app's core on purpose so it does not couple to what is in production. The duplication is deliberate and cheap debt.
Go deeper

What is aligned by hand and declared in the code: the currency catalog, the Rate model and the definition of the nine chips, with a comment on each side pointing to the other file. The day there is a third Dart consumer, the package stops being overkill; it is in the retrospective.

WHY
Why is the APK downloaded from the domain itself?
The web is live and people already visit; the app does not depend on a store approving it. The APK is a static file under /downloads on the same hosting: no extra server, no separate pipeline. Trust comes from the domain the user is already visiting, not from a third-party repository.
Go deeper

The Windows installer follows the same path: Inno Setup, no administrator privileges, installs into the user profile and is unsigned, so SmartScreen warns the first time. Signing it costs an annual certificate, and today it is not justified.

Cost for the user: enabling "install from unknown sources" once, and no automatic update: for the next version you have to go back to the web.
06 · What I would do differently

Five decisions I would make the other way today

I did
A 7-day history in the model with seven copies of the average
Today I would
Do not expose data until you have it. No source publishes history; a flat chart informs nobody and sets an expectation the product does not meet.
I did
The version shown on the web written by hand ("Android · v1.0")
Today I would
Read it from versionName at build. The gap between what the web says and what gets installed is trivial to cause and silent to detect.
I did
convert() and the Rate model without unit tests
Today I would
Write them from the first commit: it is pure code, the ideal case. The expression evaluator in the Windows launcher was born with its tests; the conversion should have been born the same way.
I did
Not saving the last rates snapshot
Today I would
Persist the last read with its time and show it marked as stale when there is no network. Today a first launch without a connection is an app with no rates.
I did
Copying the app core into the Windows launcher
Today I would
When the second Dart consumer appeared, extract a local package. Copying was right to avoid touching production; leaving it copied no longer is.
07 · If you take three things away

Recap

1
One amount, six currencies: the calculator produces a snapshot, not a result, because at a Venezuelan register nobody needs a single conversion.
2
Zero servers and $0/month: the client reads four public sources in parallel, each one can fail on its own, and everything that is saved stays on the phone.
3
Today's rate is not the last one published: the BCV publishes with a value date, and on a banking Monday reading the most recent row overcharges by 1.17 %.
Links