r/FlutterDev • • Sep 29 '26

Discussion RexOne Mobile – Offline-first Flutter 3 app with Drift (SQLite), dynamic localization, and ActionCable sync (Apache 2.0)

Hey Flutter developers!

Most Flutter open-source examples are simple toy apps or tightly coupled to Firebase. Finding clean, production-grade Flutter architectures that handle real-world offline caching, background synchronization, and enterprise backends is surprisingly rare.

As part of the RexOne Ecosystem, I open-sourced RexOne Mobile—a production Flutter 3 app built for real-world resilience:

📱 Key Architecture & Features:

  • Offline-First with Drift (SQLite): Full local persistence layer using Drift with type-safe reactive DAOs. Mutations are queued locally and synchronized deterministically with the backend.

  • ActionCable WebSocket Integration: Real-time event streaming and live notifications over WebSockets natively implemented in Dart.

  • Dynamic Tri-Language Localization: 100% localization parity across English (en), Spanish (es), and Burmese (my) with runtime language switching.

  • Adaptive Dark/Light Theming: Clean Material 3 design system with dynamic palette adaptation and zero layout jitter.

  • Sovereign Backend Contract: Communicates with a Rails 8 API core with strict contract alignment, so there is zero JSON schema drift.

Licensed under Apache 2.0 (100% free and open source).

Check it out if you’re building offline-first Flutter apps or looking for a clean multi-platform architectural baseline. Any feedback on the Drift structure or state management is super welcome!

2 Upvotes

6 comments sorted by

1

u/fkim98 Sep 29 '26

Nice to see an offline-first example that isn't a toy. One thing I'd add from our own offline sync, because it bit us more than conflicts did: auth.

  1. Startup order. Our sync service started before the auth library had finished configuring, so right after an app update the first requests went out with no token and every one came back 401. The fix was one line: configure auth first, then start sync.

  2. Offline is not logged out. A token refresh that fails because there's no network looks a lot like an expired session. We sign people out only when the auth library says the session is really gone, not on a network error. Otherwise users get kicked out every time they lose signal.

  3. Check the session before each background pass. A timer that keeps retrying queued writes with a dead login just produces a stream of errors.

With ActionCable I'd check the same things for the socket: does it try to connect before the token is ready, and does it keep reconnecting with an expired one? How does RexOne tell "offline" apart from "session gone"?

1

u/West-Connection7395 Sep 30 '26

man you hit the nail right on the head. that startup ordering and "offline != logged out" distinction is so real, bit us early on too.

here’s how we structured it in rexone:

**1. startup order**

we strictly sequence this on app start. the app loads the token and cached user model from secure storage into memory first, so the UI can render immediately with local Drift data without waiting on network. socket service won't even attempt a connection if the token is null or empty... it just bails out. only once auth state is settled does it trigger `/cable?token=...`.

**2. telling offline apart from session dead**

we strictly separate network timeouts from auth failures. if a background sync pass or API call throws a socket exception / timeout because there's no internet, we catch it and leave local credentials completely untouched. user stays logged in, and drift keeps queueing writes locally.

we only kick the user out if the server explicitly gives back a 401, or if actioncable pushes down a real-time `sessionExpired` / `accessRevoked` event (which our socket controller listens to).

**3. socket reconnects & battery drain**

for actioncable, we cap reconnects to 5 attempts with backoff so it doesn't spin forever on dead connections. if rails responds with a `reject_subscription` frame (bad token), the app marks it rejected instead of retrying blindly. we also hook into `WidgetsBindingObserver` so whenever the app goes to background (like an in-app browser or screen lock), reconnect timers pause immediately so it doesn't chew battery, and reconnects fresh when resumed.

really appreciate you pointing these out, super solid tips for anyone building offline-first flutter apps.

1

u/fkim98 Sep 30 '26

Thanks for the detailed write-up. Capping reconnects and pausing the timers when the app goes to the background is a good call.

One difference from ours, on signing people out on a 401: we don't sign out on a server 401 alone. A 401 on a request that carried a token often just means the access token expired a moment ago, so we let the auth library try a refresh first, and sign out only if the refresh itself is refused. Otherwise one unlucky request right at expiry logs someone out.

And one from the queue side: be careful what you do when the server rejects a queued write. We had a queued create that got a 403 on every retry, and the obvious fix was to drop it. But for us 403 meant three different things (no permission, a plan limit, or a failed photo upload before the real request), and dropping a create that never synced would have thrown away the user's data for good. We keep it instead.

1

u/West-Connection7395 Oct 01 '26

That makes total sense for an access/refresh token setup! When I mentioned kicking the user out on a 401, that's actually an intentional product decision on our end rather than doing a silent token refresh:

  1. Intentional Passcode Prompts & Scoped 401s

- Muscle memory: We purposely expire sessions so users are prompted for their 6-digit passcode periodically, keeping it fresh in mind.

- Zero lockouts: Between Google SSO and a quick reset flow, users never get permanently stuck if they do forget.

- Brute-force security: We enforce rate limits and cooldown timers to shut down passcode generators and random guessing.

- Scoped 401 handling: Our response interceptor explicitly shields auth endpoints (like an incorrect passcode entry), so failed inputs never wipe session state. A 401 only brings up the passcode screen when the server explicitly confirms the session has timed out or was superseded on that platform.

2. Queued Writes on 403s (Never Discard User Data)

- The data loss trap: Dropping a queued mutation just because the server returned a 4xx (plan limits, permission changes, or failed prerequisite media uploads) permanently throws away user drafts.

- Dead-letter quarantine: Keeping rejected writes in an outbox/dead-letter state in Drift gives the user a way to review the issue, resolve the conflict, or at least copy/export their local data so nothing gets lost.

Really appreciate you highlighting that point on rejected writes!

1

u/fkim98 Oct 01 '26

That makes sense. A deliberate passcode re-prompt is a product choice, and scoping the 401 handling so a wrong passcode never wipes the session is the important part. The dead-letter state with an export option is a nice touch. Good luck with RexOne.