Skip to content

EquiTrail — Architecture & Design Decisions

Every significant decision is logged here with rationale so future sessions don't re-debate settled choices.


Infrastructure

Cloud Region: europe-west4 only

Decision: All cloud infrastructure (Firebase, Cloud Functions, any future services) must be in europe-west4 (Amsterdam) or the closest EU region. Why: User is in the Netherlands; GDPR compliance; latency; user data stays in the EU. Applied: Firebase project, GraphHopper server on Oracle Cloud Netherlands.

Routing Server: Oracle Cloud ARM (Netherlands)

Decision: Self-hosted GraphHopper on Oracle Cloud free-tier ARM instance. Why: Free, performant, data stays in NL, horse routing profile customizable. SSH rule: ONLY via Tailscale IP 100.126.14.49. Public IP 152.70.54.93 is blocked by UFW and must never be used.

GraphHopper Round-Trip Algorithm

Decision: Use ?algorithm=round_trip&round_trip.distance=Nm for loop routes, NOT via-point approach. Why: Via-point creates straight out-and-back; round_trip generates true circular loops.


Android Build

Package: com.nossie.equitrail (not com.equitrail.equitrail)

Decision: Single package name for phone AND wearable. Why: Google Play wear:internal track requires watch package = phone package. History: Previously had com.equitrail.equitrail which caused SHA-1/Google Sign-In errors (DEVELOPER_ERROR 10). Permanently deleted old Firebase apps.

Wearable Distribution: wear:internal track (separate AAB)

Decision: Phone AAB → internal track; Wearable AAB → wear:internal track. Both uploaded separately. Why: AGP 9.x removed wearApp() bundling; Play Console requires separate AAB per form factor. Note: During internal testing, watch auto-install from phone is disabled — testers must install directly from Play Store on watch.

Version Convention: watch = phone + 1000

Decision: phone versionCode N → watch versionCode N+1000. Why: Easily distinguishable in Play Console; no conflicts.

Build Script: flutter build appbundle (not build_release.sh)

Decision: Use flutter build appbundle --release directly instead of scripts/build_release.sh for speed. Why: build_release.sh runs flutter analyze which can take 2+ minutes and sometimes hangs. Direct command is faster.


iOS

No App Store Distribution Certificate Yet

Decision: iOS TestFlight blocked until Apple Developer Program enrollment (€99/year). Why: Distribution certs require paid enrollment. Status: Guide written at docs/apple-developer-testflight-setup.md. Team ID SAF6DV6MH7 is in Xcode.

Development Install: flutter run --release

Decision: For device testing before enrollment, use flutter run --release --device-id=00008030-0002196E11E3402E. Why: Development signing works without paid cert for personal device testing.


App Architecture

State Management: Riverpod 2.x

Decision: Riverpod (not BLoC, not Provider). Why: Compile-time safety, no BuildContext, cleaner than BLoC for this scale.

Local Storage: Hive

Decision: Hive for all local persistence (rides, horses, settings). Why: Fast, no-SQL, Flutter-native, works offline. Rule: New Hive fields must use positional field reading with backward-compatible defaults (check numOfFields or use safe defaults for missing fields).

Decision: go_router with StatefulShellRoute.indexedStack for bottom nav. Why: Deep linking, named routes, type-safe, maintained by Flutter team. Constraint: Bottom nav branches are fixed at build time; customization is done by showing a subset of branches in ScaffoldWithBottomNav.

Map: flutter_map + OpenStreetMap tiles

Decision: flutter_map (not Google Maps). Why: Free, no API key for basic use, OSM data is horse-routing aware, no Google dependency.

Translations: Custom AppLocalizations (not Flutter gen-l10n)

Decision: Single app_localizations.dart file with NL/EN/FR/DE maps. Why: Simple, no codegen complexity, easy to add/edit strings in one file. Languages: NL (primary), EN, FR, DE.


Security

TamperGuard + Play Integrity

Decision: Custom TamperGuard (root/Frida/emulator detection) + Play Integrity API. Why: PRO subscription must be server-verified; prevent sideloaded cracked APKs.

HMAC Request Signing

Decision: All API requests signed with HMAC-SHA256 via SecureClient. Why: Prevents fake clients and replay attacks.

Credentials Never Committed

Rule: credentials.env, google-services.json, GoogleService-Info.plist, *.jks, play_service_account.json are in .gitignore and NEVER committed. Note: All these files live in the workdir — not in ~/Downloads or elsewhere.


Features

Calorie Calculation: Gait-time × Weight

Decision: Estimate calories from gait-time distribution (walk/trot/canter seconds) multiplied by weight-adjusted base rate. Why: No accelerometer or heart rate sensor required; reasonable approximation for a riding app. Baseline: Rider 70 kg, horse 550 kg. Users set actual weights.

Jump Detection: GPS Altitude

Decision: Detect jumps from GPS altitude rise/fall pattern (≥0.4 m rise, descent within 5s). Why: No IMU/accelerometer dependency; works with phone GPS. Limitation: Less accurate than accelerometer; GPS altitude noise can cause false positives.

Decision: Use Nominatim (OSM geocoder) for address autocomplete with 400ms debounce. Why: Free, no API key, EU data, consistent with the rest of the map stack. Countries: nl,be,de,fr.

Bottom Nav Customization: Hive-persisted subset

Decision: All 6 branches always defined in router; ScaffoldWithBottomNav shows a user-configurable subset (3-5 items). Why: Can't change router branches at runtime; showing a subset is safe and simple. Constraints: Home (0) and Settings (5) always visible.


Payments & Pricing

Pricing: €10/mnd website, €11.99/mnd stores

Decision: Website price = €10.00/mnd (€90/jaar). Store price = €11.99/mnd (€109.99/jaar) assuming Apple Small Business Programme (15% commission). Why: Website has 0% commission → cheaper. Stores take 15% (Google, Apple SBP) → need higher price to net same amount. Transparent: pricing page explains this clearly. Applied: equitrail.horse/pro live. AppConstants.proPageUrl set.

Apple Small Business Programme: assume 15%

Decision: Assume Apple SBP approval (15% commission) for all pricing and planning. Why: EquiTrail is new, revenue < $1M → almost certainly qualifies. Re-qualify annually. Action: Apply at developer.apple.com/app-store/small-business-program/ when enrolling as Apple Developer. Fallback: If not approved, standard 30% → price would be €14.99/mnd.

Website deploy: FTP only (SFTP does NOT work)

Decision: Use plain FTP (port 21) for all website deployments to equitrail.horse (Plesk). Why: SFTP gives connection timeouts on this Plesk server. FTP with set ftp:ssl-force false works. Command: lftp ftp://equitrail.horse -u "$WEBSITE_FTP_USER,$WEBSITE_FTP_PASS" -e "set ftp:ssl-force false; put FILE -o /httpdocs/FILE; quit" Credentials: WEBSITE_FTP_USER and WEBSITE_FTP_PASS in credentials.env.

Website payments via Stripe (before Play/App Store)

Decision: Website Stripe integration (#83) is built before Google Play Billing (#84) or App Store IAP (#85). Why: No store dependency, no Apple enrollment needed, full revenue to EquiTrail, faster to ship. Needs: Stripe account + publishable/secret keys from user → add to credentials.env.

Discord

One bot, one token — RouteHelper merged into main bot

Decision: RouteHelper GPX functionality merged into discord_bot/bot.py instead of a separate bot process. Why: Discord delivers each event to only one connection per token. Two processes with same token compete for events — one misses everything. Solution: one bot handles all functionality. Applied: v3.4.1+48, SHARE_ROUTES_CH = 1510606612656558100.

Discord features channels: free/preview/exclusive

Decision: Three read-only channels in 🎯 FEATURES category: - #free-features: visible to all Ruiter - #pro-preview: all Ruiter, teaser + pricing comparison - #pro-exclusive: PRO + Ambassador + Influencer + Founder only Why: Lets potential PRO buyers see value without revealing full detail. Natural upsell.

RouteHelper asks for name when GPX has no tag

Decision: When a GPX file lacks a <name> element, bot asks uploader in the channel (60s timeout). Why: "Waypoint" or filename are poor route names. Better UX to prompt. Applied: bot.wait_for('message', timeout=60) → reply used as name, then deleted.

Health & Medical Disclaimer (#103)

Disclaimer scope: all fitness/health data

Decision: Add explicit health & medical disclaimer to Terms of Service (all 4 languages), app onboarding (one-time modal), and inline near calorie/jump UI. Also include in Play Store and App Store listing descriptions. Why: EU consumer protection law and the Medical Device Regulation (MDR 2017/745) require that apps which display health-related data clearly disclaim they are not medical devices. Calorie estimates, gait analysis, jump detection and stride data could otherwise be misinterpreted as medical-grade measurements. Applied: terms.html — NL §9, EN §9, DE §3, FR §3. All four languages include: - Not a medical device (MDR) - Data are estimates, not clinically certified - Consult a doctor/vet for real health decisions - EquiTrail accepts no liability for health decisions based on app data - Applies equally to rider AND horse data

Still TODO (#103): - In-app onboarding: one-time modal on first calorie/jump screen visit - Inline note under calorie display in tracking UI: small grey text "Schatting — niet voor medisch gebruik" - Play Store listing: add to "Full description" — standard fitness app caveat - App Store listing: same


Shared PRO Subscription

Horse PRO — co-rider discount model

Decision: Introduce "Horse PRO" subscription alongside individual "Rider PRO". A Horse PRO subscription is linked to a specific horse and grants PRO benefits to all verified co-riders of that horse at a discounted rate.

Pricing: | Plan | Price | Who benefits | |------|-------|-------------| | Rider PRO (solo) | €10/mnd, €90/jaar | One rider | | Horse PRO (shared) | €15/mnd, €135/jaar | All co-riders on that horse |

Why: Two riders sharing one horse may not both pay full price — realistic in the Dutch market where horses are frequently shared (pensionrijder, half-pension). Horse PRO at 1.5× solo price is fair for 2 riders (€7.50 each equivalent).

Fraud prevention: 1. Horse PRO requires both riders to be listed as co-riders on the horse BEFORE subscription activates. 2. Horse owner subscribes → server writes pro_horse_ids: [horseId] to both rider Firestore docs. 3. If a rider is removed from co-riders list → their PRO access for that horse revokes within 24h. 4. Max 3 co-riders per Horse PRO subscription (prevents "family plan abuse"). 5. Same rider cannot hold more than one Horse PRO at a time (prevents stacking discounts).

Backend: Cloud Function onHorseProPurchase validates co-rider list, writes PRO grants, sets expiresAt. App reads isPro from merged rider + horse PRO status.

Implementation track: #102 (backlog). Needs Stripe + Play Billing first (#83, #84).


Monitoring & Ops

Dashboard: Python CLI + Web (Flask via Portainer)

Decision: Two-tier dashboard — terminal (scripts/dashboard.py) for dev sessions, web (scripts/dashboard_web.py in Docker) for persistent monitoring via Portainer. Why: Terminal dashboard is instant for ad-hoc checks. Web dashboard persists 24/7 with auto-refresh, accessible from any device on the network. Port: 8088 (configurable in Portainer via DASHBOARD_PORT).

Service status: Uptime Kuma (not Grafana)

Decision: Use Uptime Kuma for service/URL uptime monitoring. Grafana is overkill until we have time-series metrics to graph. Why: Uptime Kuma is lightweight, visual, already running in user's Portainer. Covers: website, routing server, Firebase, Discord API, Oracle SSH. Config in docker/uptime-kuma-monitors.json. Future: Add Grafana only when we have Prometheus metrics (e.g., from the routing server or custom app metrics).

Routing server fallback

Decision: Add a secondary routing fallback in RoutingService for when GraphHopper is unreachable. Fallback chain: (1) Oracle GraphHopper → (2) OSRM demo API (temporary, rate-limited) → (3) graceful error with GPS-only mode. Why: Single routing server is a SPOF for navigation. App works offline but navigation is fully broken when server is down. A degraded fallback is better than an error. Implementation: #101 (backlog). Detect HTTP timeout → retry with fallback URL → surface degraded banner in UI.


Chat Export

Auto-export on Exit: Stop Hook

Decision: ~/.claude/settings.json Stop hook runs scripts/auto_export_chat.py on every exit. Why: User wants permanent record of sessions for context continuity. Output: docs/chats/chat_YYYYMMDD_HHMM.txt (gitignored).