DEV Community

Building an offline-first travel app in .NET MAUI (on-device OCR, currency & maps, no backend)

A build note from Horizon Software, a one-person Android studio. WanderWallet is a travel budget app, and the whole thing runs on the phone: no account, no backend, no cloud. Here's how the parts that look like they need a server actually work without one. The one constraint that shaped everything WanderWallet has a single non-negotiable rule: it has to work with no signal. You're three countries into a trip, your phone's in airplane mode to dodge roaming charges, and you still need to know whether you're on budget. That one requirement quietly makes most of the architectural decisions for you - no login, no server round-trips, and every feature that would normally lean on a cloud API has to earn its keep another way. The stack is deliberately boring: .NET MAUI (Android-first), CommunityToolkit.Mvvm, sqlite-net-pcl for storage, and SkiaSharp for anything I draw myself. Everything the app records lives in a local SQLite database on the device and nowhere else. "Backup" is a file you export and keep - there's no server to back up to. The three features people assume need a backend turned out to be the most interesting to build, precisely because they don't. 1. Currency conversion that survives airplane mode A travel budget app that can't convert currencies offline is useless at exactly the moment you need it. So rates aren't fetched on demand. Whenever the app happens to have a connection it refreshes exchange rates for ~155 currencies and caches the whole table locally. From then on every conversion is local arithmetic - a connection only ever buys you a fresher table, never the ability to convert. The design decision that took me longest to get right: capture the conversion immutably, at entry time. Each expense stores the original amount, its original currency, the converted home-currency amount, and the exact rate used - and that rate is never recalculated: public class Expense { public double Amount { get; set; } // in OriginalCurrency public string OriginalCurrency { get; set; } // e.g. "JPY" public double AmountHome { get; set; } // converted to home currency public double ExchangeRateUsed { get; set; } // frozen - never recalculated } It's tempting to store only the original amount and reconvert on the fly against the current table. Don't. If you reconvert history every time rates move, that 3,000-yen lunch silently changes price weeks later and your past budgets drift. What you spent that day is a fact - freeze it. 2. On-device receipt OCR (nothing leaves the phone) Point the camera at a receipt and the app fills in the amount, date and merchant. The obvious implementation is a cloud OCR API - and it's the wrong one here twice over: it needs a signal, and it ships photos of your receipts to someone else's server. So the text recognition runs on the device, through Plugin.Maui.OCR, which sits on top of the platform's built-in recognizer (ML Kit on Android). That hands you raw text and bounding boxes, which is only half the problem. Turning a messy wall of receipt text into amount / date / merchant is a custom parser: pick out money-shaped tokens, prefer the ones sitting near words like "total", validate against date patterns, and fail gracefully when a crumpled receipt returns nonsense. That heuristics layer is where the real work went - and it never has to phone home. 3. An offline map with no map SDK "Show my trip on a map" almost always pulls in a mapping SDK streaming tiles over the network - the one thing I don't have. So the map is drawn by hand, and it's simpler than it sounds. There's a single bundled equirectangular world image (a Creative-Commons one from Wikimedia), a tiny plate carrΓ©e projection that turns any latitude/longitude into a 0..1 coordinate on that image, and SkiaSharp to drop a pin for each leg and connect the route: // The entire projection. Lat/lon in, 0..1 canvas coordinates out. public static (double X, double Y) Project(double lat, double lon) { var x = (lon + 180.0) / 360.0; var y = (90.0 - lat) / 180.0; return (Math.Clamp(x, 0, 1), Math.Clamp(y, 0, 1)); } One image, a few lines of maths, a canvas. No SDK, no tiles, no API key, no network - and because it's all SkiaSharp it renders straight to a PNG you can share. The pins come from a small bundled table of country centroids, so even the "where is this place" lookup is offline. Auto-budgets from a bundled dataset The same offline-first thinking runs through the budgeting. A bundled JSON dataset of ~139 destinations - each with typical daily costs at three travel styles - lets the app suggest a per-leg budget the moment you add a stop, no lookup required. (It also quietly powers a little travel budget calculator on the website.) Being honest about the network The app isn't network-free, and it'd be dishonest to claim otherwise. Exactly two things reach out: an optional exchange-rate refresh, and ads (AdMob via Plugin.MauiMtAdmob, with Google's UMP for consent). Everything else - trips, expenses, receipts, the map, backups - is local. "Offline-first" here means the app is completely usable in airplane mode, and the worst a lost connection does is leave you on a slightly stale rate table. Would I build it this way again? Offline-first costs you up front. You re-implement things a backend would have handed you for free, and you think harder about what "source of truth" means when there's no server to arbitrate. But in return you get a product promise you can actually keep: no account, nothing uploaded, works anywhere. For a travel app, that turned out to be the pitch. WanderWallet is free on Google Play if you want to see it in action, and there's a feature rundown here. Happy to talk MAUI, SkiaSharp or offline-first trade-offs in the comments. Built with .NET MAUI by Horizon Software - a small studio making simple, offline-first Android apps. Top comments (0)

Comments

No comments yet. Start the discussion.