Real-time fuel station prices for Spain and Portugal — built with Expo.
Siphon is a React Native + Expo app for finding fuel station prices across Spain and Portugal. It uses MapLibre GL for maps, Expo Router for navigation, NativeWind for styling, and an offline cache (AsyncStorage + expo-file-system) so the app works without a live connection (unless to update data).
Stack: Expo SDK 57 · React Native · React 19 · MapLibre GL · Expo Router · NativeWind · New Architecture enabled
This is a shared React Native + Expo codebase — the TypeScript in src/ and app/ runs on both platforms as-is; you write it once and it renders native UIView (iOS) or ViewGroup (Android) under the hood. The only Platform.OS branching in the app is two small tweaks:
src/utils/location.ts- Apple Maps vs. MapLibre (OpenFreeMaps)app/(tabs)/_layout.tsx- tab nav bar
Everything else — features, UI, logic — lives in shared code. No double work.
- Stations search with filters: brand, country, fuel type, price range, city, max distance, etc
- Favorites
- 5-language support: English, Portuguese, Spanish, French, German
- Theme support: light / dark (future implementation: custom theme colors)
- Customizable location marker (4 built-in icons, SVGs, or a custom image) - Placeholders still
- Price-history charts per fuel type, 90-day rolling window (live in server - cached on device, max 100mb for 90 days)
- Station detail sheet — prices per fuel unit (€/L, €/kg, €/m³), opening schedule, services, payment methods, margin, directions, copy address, distance (API source & Crowdsource)
- GPS-based location with cached last known location, doesn't follow user. Used only when requested, so no battery drained continually
- Node.js
- iOS: macOS, Xcode with the iOS Simulator, CocoaPods
- Android: Android Studio with an emulator, or a physical device with USB debugging enabled
npm install
npx expo install --check
npx expo install --fixnpx expo prebuild --clean --platform ios
npx expo run:iosnpx expo prebuild --clean --platform android
npx expo run:androidnpx expo prebuild --clean --platform android
cd android
gradlew assembleReleaseiOS release builds go through Xcode, not Gradle:
npx expo run:ios --configuration Release
ios/andandroid/are gitignored and generated bynpx expo prebuild. Native config lives declaratively inapp.json(underexpo.ios/expo.android), not in the committed native folders.
Releases are built in the cloud by EAS and attached to a GitHub Release, so the app's "Check for updates" can point users at a single source of truth.
The git tag is the single source of truth. Just create and push a vX.Y.Z tag (e.g. v1.1.0) — nothing else needs to change:
git tag v1.1.0
git push origin v1.1.0The GitHub Action (.github/workflows/release.yml) derives everything from the tag: it patches app.json (setting version, android.versionCode = major*10000 + minor*100 + patch, and ios.buildNumber), builds an Android APK via EAS, and attaches it to the release at https://github.com/8041q/Siphon/releases. Tags that aren't vX.Y.Z fail fast. Requires the EXPO_TOKEN repo secret (Expo → Account Settings → Access Tokens).
- On launch the app silently verifies against the GitHub releases API whether a newer version exists (no download, result cached 30 min).
- Settings → Updates shows the status, a manual "Check for updates", and — when one exists — a "Download update" button:
- Android: opens
releases/latest/download/siphon.apk(user taps to install the APK). - iOS: opens the release page for now; swap the URL in
src/hooks/useAppUpdate.ts(getUpdateUrl) when a real iOS distribution path exists.
- Android: opens
On app launch, sync runs once, in order:
checkForUpdates()— conditional GET on the root manifest using an ETag (If-None-Match). If nothing changed, the server returns304 Not Modifiedwith no body, and no countries are flagged as changed.syncAll(changedCountries)— refreshes only the tiles whose server-side hash differs from the cached one. On a304, this step makes zero network calls.checkHistoryUpdates()— downloads missing or changed price-history files, gated by the "Save price history on device" setting (on by default).
All three steps only touch the network when something actually changed — on a no-change day, the app runs entirely from cache.
To protect the GitHub data source, sync is guarded client-side (persisted in file-backed storage so it survives cache clears): a rolling hourly request budget (~300), a minimum interval between sync cycles (10 min), and backoff on 429/403 responses. When a limit is hit, the app runs from cache and shows a short "sync paused" notice instead of spamming GitHub.
The "Search this area" button reads from the cached data for the current map region, and only hits the network if that country was flagged as changed at launch.
Tiles for Spain use a 3×3 grid-key lookup to handle boundary/edge cases between regions.
Fuel prices come from the Spanish and Portuguese government fuel-price feeds, processed by the companion SiphonAPI project and served to the app as a static manifest plus per-tile data.
On launch, the app resolves a starting location via GPS, falling back to the last known location when GPS is unavailable or permission is denied. Precise GPS (getCurrentPositionAsync) is only requested once automatically at launch, and again whenever you tap My Location - it does not track your position continuously, so it won't drain your battery.
If a native build fails after a config change, clean the build artifacts:
cd android
./gradlew clean
rm -rf .cxxCMake version bellow 4.1.2 may not work, you should use the latest version instead due to a know bug in previous versions.
See docs/API-Calls-Reference.md for the full client/API reference.