mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
1# Lazurite 2 3Cross-platform Bluesky client built with Flutter and Dart using Material You (M3) design. 4 5## Features 6 7- Auth + identity resolution 8- Home timeline + threads + profiles + search + notifications 9- Compose (with media) + likes/reposts/replies 10- DMs (read + write): convo list, messages, send, read state 11- Local features: 12 - Smart folders (local-only "feeds" built from cached posts) 13 - Local post drafts (offline compose; later publish) 14 15### Maybes 16 17- Firehose ingestion, custom feed generator hosting, heavy moderation tooling 18 19## Architecture 20 21### Stack 22 23Flutter + Dart, Drift (SQLite), Dio (networking), Riverpod (state), go_router (navigation) 24 25### Data flow 26 27- Authenticated requests → user's PDS 28- Public reads → public.api.bsky.app 29- Chat → PDS with service proxy header 30 31### Storage 32 33- Drift tables for posts, profiles, timeline, notifications, DMs, 34 [smart folders](#smart-folders), and [drafts](#drafts) 35- Auth secrets in secure storage (not Drift). 36 37## Local Dev 38 39Use `just` targets: 40 41- `just format` - runs `dart format lib test` 42- `just lint` - proxies `flutter analyze` 43- `just test` - executes the full `flutter test` suite 44- `just gen` - invokes `dart run build_runner build --delete-conflicting-outputs` 45- `just check` - performs format + lint + test before commits 46 47For local smoke runs: 48 49```sh 50flutter pub get 51flutter run -d android 52flutter run -d chrome 53flutter run -d ios 54``` 55 56<!-- markdownlint-disable MD033 --> 57<details> 58<summary>Endpoint Routing & Host Strategy</summary> 59 60### Host Selection 61 62Host selection is a first-class concern: 63 641. **Unauthenticated, public reads** (fast path) 65 66 - Base URL: `https://public.api.bsky.app` 67 - Use for: browsing without login (profiles, public feeds, threads, search) 68 692. **Authenticated requests** (normal logged-in mode) 70 71 - Base URL: the user's PDS (discovered from identity resolution) 72 - Why: authenticated requests are "usually made to the user's PDS, with automatic service proxying" 73 743. **Service proxying** (chat + some services) 75 - Make the request to the user's PDS, and set the service proxy header to target the downstream service 76 - Bluesky chat endpoints require proxying to `did:web:api.bsky.chat` 77 78### Endpoint Map by Feature 79 80#### Identity + Sessions 81 82| Endpoint | Method | Host | Auth | Notes | 83| -------------------------------------- | ------ | ---- | ---- | ------------------------------------------------- | 84| `com.atproto.identity.resolveHandle` | GET | PDS | None | Resolve handle → DID | 85| `com.atproto.identity.resolveDid` | GET | PDS | None | Resolve DID → DID document | 86| `com.atproto.identity.resolveIdentity` | GET | PDS | None | Resolve handle or DID → DID doc + verified handle | 87| `com.atproto.server.createSession` | POST | PDS | None | Legacy login (app passwords) | 88| `com.atproto.server.getSession` | GET | PDS | User | Check current session | 89 90#### Core Reading 91 92| Endpoint | Method | Host | Auth | Notes | 93| ----------------------------- | ------ | ------------- | ----------- | ---------------------------- | 94| `app.bsky.feed.getTimeline` | GET | PDS / AppView | User | Home timeline | 95| `app.bsky.actor.getProfile` | GET | PDS / AppView | None / User | Actor profile | 96| `app.bsky.feed.getAuthorFeed` | GET | PDS / AppView | None | Actor feed (posts + reposts) | 97| `app.bsky.feed.getPostThread` | GET | PDS / AppView | None / User | Thread view | 98| `app.bsky.feed.getPosts` | GET | PDS / AppView | None | Hydrate posts by AT-URI | 99| `app.bsky.feed.searchPosts` | GET | PDS / AppView | None / User | Search posts | 100| `app.bsky.feed.getLikes` | GET | PDS / AppView | None | Who liked a post | 101| `app.bsky.feed.getRepostedBy` | GET | PDS / AppView | None | Who reposted a post | 102| `app.bsky.graph.getFollowers` | GET | PDS / AppView | None | Get followers | 103| `app.bsky.graph.getFollows` | GET | PDS / AppView | None | Get follows | 104 105#### Notifications 106 107| Endpoint | Method | Host | Auth | Notes | 108| ----------------------------------------- | ------ | ---- | ---- | ------------------ | 109| `app.bsky.notification.listNotifications` | GET | PDS | User | List notifications | 110| `app.bsky.notification.getUnreadCount` | GET | PDS | User | Unread count | 111 112#### Writing (Posts, Likes, Reposts, Follows) 113 114| Endpoint | Method | Host | Auth | Notes | 115| ------------------------------- | ------ | ---- | ---- | ------------------------------- | 116| `com.atproto.repo.createRecord` | POST | PDS | User | Create a record (generic write) | 117| `com.atproto.repo.putRecord` | POST | PDS | User | Update a record | 118| `com.atproto.repo.deleteRecord` | POST | PDS | User | Delete a record | 119| `com.atproto.repo.applyWrites` | POST | PDS | User | Batch writes (atomic-ish) | 120| `com.atproto.repo.uploadBlob` | POST | PDS | User | Upload media blob | 121 122Collections used with createRecord/putRecord/deleteRecord: 123 124- Post: `app.bsky.feed.post` 125- Like: `app.bsky.feed.like` 126- Repost: `app.bsky.feed.repost` 127- Follow: `app.bsky.graph.follow` 128 129#### DMs (Bluesky Chat via PDS proxy) 130 131| Endpoint | Method | Host | Auth | Proxy | Notes | 132| ----------------------------- | ------ | ---- | ---- | ----------------------- | ------------------------- | 133| `chat.bsky.convo.listConvos` | GET | PDS | User | `did:web:api.bsky.chat` | Convo list (inbox) | 134| `chat.bsky.convo.getMessages` | GET | PDS | User | `did:web:api.bsky.chat` | Fetch messages in a convo | 135| `chat.bsky.convo.sendMessage` | POST | PDS | User | `did:web:api.bsky.chat` | Send message | 136| `chat.bsky.convo.updateRead` | POST | PDS | User | `did:web:api.bsky.chat` | Mark convo read | 137| `chat.bsky.convo.acceptConvo` | POST | PDS | User | `did:web:api.bsky.chat` | Accept convo request | 138 139**Implementation note:** The "proxying header" mechanism is standardized in XRPC HTTP specs; implement it once in Dio as an interceptor/RequestOptions hook, then reuse. 140 141### Local-only Features 142 143#### "Smart" folders 144 145- Definition: locally stored queries over cached timeline/threads + local labels 146- Example folder rules: "Unread", "From follows only", "Mentions + keyword", "Media only", "Saved" 147- Inputs (network): whatever you choose to cache (timeline/author feed/thread/etc.) 148- Output: purely local projection; no server-side "custom feed generator" required 149 150#### Local post drafts 151 152- Stored in Drift only; not published until you call repo.createRecord/applyWrites 153 154</details> 155 156<details> 157<summary>Routing</summary> 158 159### Why go_router? 160 161- URL-based navigation (deep links, shareable routes, desktop parity) 162- Nested navigation with persistent tab state 163- Auth gating via redirect() 164- Path + query parameters, nested routes, and redirection support 165 166### Router Shape 167 168Use `StatefulShellRoute.indexedStack` for bottom tabs so each tab preserves state (scroll position, nested stacks). 169 170**Tabs (StatefulShellRoute branches):** 171 172- Home 173- Search 174- Notifications 175- DMs 176- Profile (Me) 177 178**Rules:** 179 180- Put "detail" routes (thread, profile, convo) as children of the branch where the user is coming from, so the bottom bar stays visible 181- Put "global modals" (compose, settings) on the root navigator so they present above tabs 182 183### Route Map 184 185| Path | Description | Notes | 186| -------------------- | ------------------- | ------------ | 187| `/splash` | (optional) | | 188| `/login` | Login screen | | 189| `/settings` | Settings screen | | 190| `/compose` | Compose modal | Root modal | 191| `/drafts` | Local-only list | | 192| `/drafts/:draftId` | Edit draft | | 193| `/home` | Home tab | | 194| `/home/t/:postKey` | Thread from home | | 195| `/home/u/:did` | Profile from home | | 196| `/search` | Search tab | | 197| `/search?q=<query>` | Search results | Query params | 198| `/search/t/:postKey` | Thread from search | | 199| `/search/u/:did` | Profile from search | | 200| `/notifs` | Notifications tab | | 201| `/notifs/t/:postKey` | Thread from notifs | | 202| `/notifs/u/:did` | Profile from notifs | | 203| `/dms` | DMs tab | | 204| `/dms/c/:convoId` | Convo detail | | 205| `/me` | Profile (me) tab | | 206| `/me/u/:did` | Profile view | | 207 208**About `:postKey`:** Don't put raw `at://did/...` in the path. Encode it as a URL-safe token (base64url(atUri) or percent-encode into a query param like `/home/t?uri=...`). 209 210### Auth Gating + Reactive Redirects (Riverpod) 211 212go_router redirects are not automatically reactive; you must provide a `refreshListenable` if you want auth changes to trigger redirects reliably. 213 214**Pattern (opinionated, reliable):** 215 216- Auth state lives in Riverpod (e.g., AuthController Notifier) 217- Create a small Listenable bridge for GoRouter.refreshListenable: 218 - `ValueNotifier<int>` "tick" 219 - `ref.listen(authProvider, (_, __) => tick.value++)` 220- In `redirect()`, read auth state and return: 221 - unauthenticated → `/login` for protected routes 222 - authenticated → redirect away from `/login` to `/home` 223 224**Protected vs public routes:** 225 226- Public allowed: `/home` (read-only), `/search`, viewing threads/profiles 227- Auth required: `/notifs`, `/dms`, `/compose` publish, follow/like/repost actions 228 229### Deep Linking (Android/iOS) 230 231- Use go_router URL paths that mirror shareable content 232- Configure Android App Links + iOS Universal Links so `https://<yourdomain>/...` can open the app to a matching route 233- Prefer path params for required IDs; query params for optional filters/search 234 235</details> 236 237<details> 238<summary>State Management</summary> 239 240### Why Riverpod? 241 242- Dependency injection (Dio clients, DB, repositories) 243- View-model state (timeline paging, thread loading, draft autosave, DM outbox) 244- Flutter's architecture guide recommends separation of concerns and "Views + ViewModels" with repositories in the data layer 245- Riverpod recommends (Async)Notifier/(Async)NotifierProvider for state that changes via user interaction 246 247### Provider Types 248 249| Type | Example | Use Case | 250| ----------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- | 251| `Provider<T>` | `Provider<AppDb>` | Singletons / resources (DB, Dio, secure storage, endpoint map) | 252| `Provider<T>` | `Provider<TimelineRepository>` | Repositories (data layer) | 253| `AsyncNotifierProvider<T, S>` | `AsyncNotifierProvider<TimelineController, TimelineState>` | ViewModels with I/O (network/db) | 254| `NotifierProvider<T, S>` | `NotifierProvider<ComposerController, ComposerState>` | ViewModels with sync state (text fields + local draft) | 255 256**Dependencies (singletons / resources):** 257 258- `Provider<AppDb>` (Drift DB) 259- `Provider<Dio>` dioPublic 260- `Provider<Dio>` dioPds 261- `Provider<AuthSessionStore>` (secure storage-backed) 262- `Provider<EndpointMap>` (single source of routing truth) 263 264**Repositories (data layer):** 265 266- `Provider<TimelineRepository>` 267- `Provider<ThreadRepository>` 268- `Provider<DmRepository>` 269- `Provider<DraftRepository>` 270 271**ViewModels (presentation layer):** 272 273- `AsyncNotifierProvider<TimelineController, TimelineState>` 274- `AsyncNotifierProvider<ThreadController, ThreadState>` 275- `NotifierProvider<ComposerController, ComposerState>` (sync + autosave) 276- `AsyncNotifierProvider<DmInboxController, DmInboxState>` 277- `AsyncNotifierProvider<DmConvoController, DmConvoState>` 278 279**Rule of thumb:** 280 281- Use `AsyncNotifier` when you do I/O (network/db) or have "loading/error/data" 282- Use `Notifier` for purely local synchronous state (text fields + local draft), while persisting via a repository 283 284### File Placement 285 286```sh 287lib/src/app/providers.dart 288 - global providers for env, dio, db, secure storage, endpoint map 289 290lib/src/infrastructure/... 291 - pure Dart classes: XrpcClient, interceptors, DAOs (no Riverpod here) 292 293lib/src/features/<feature>/presentation/<feature>_controller.dart 294 - Notifier/AsyncNotifier implementation 295 - holds paging cursors, refresh logic, optimistic UI, etc. 296 297lib/src/features/<feature>/domain/usecases/ 298 - optional: thin usecases called by controllers, especially for complex flows 299 (publish draft pipeline, DM outbox flush) 300``` 301 302### Testing Conventions 303 304- Unit test controllers with `ProviderContainer`: 305 - Override repositories with fakes 306 - Assert state transitions: loading → data, optimistic updates, retry behavior 307- DAO tests run against an in-memory/temporary Drift database 308- Network tests mock Dio adapters (or replace XrpcClient in providers) 309 310</details> 311 312<details> 313<summary>Database Schema (Drift)</summary> 314 315### Principles 316 317- Use stable keys (DID, at:// URIs) 318- Store raw JSON payloads for forward compatibility, plus minimal indexed columns 319- Keep auth secrets out of Drift; store them in secure storage 320 321### Core Cache Tables 322 323| Table | Primary Key | Purpose | 324| ---------------- | -------------------- | -------------------------------------------------------- | 325| `accounts` | `did` | User accounts (handle, pdsUrl, active) | 326| `profiles` | `did` | Profile data (handle, json, updatedAt) | 327| `posts` | `uri` | Post data (cid, authorDid, textPreview, indexedAt, json) | 328| `timeline_items` | `(feedKey, postUri)` | Timeline cache (sortKey, cursor) | 329| `notifications` | `id` | Notifications (indexedAt, seenAt, json) | 330 331### DMs 332 333| Table | Primary Key | Purpose | 334| ------------- | ---------------------- | --------------------------------------------------------------------- | 335| `dm_convos` | `convoId` | Conversation metadata (lastMessageAt, unreadCount, muted, json) | 336| `dm_members` | `(convoId, memberDid)` | Conversation members | 337| `dm_messages` | `(convoId, messageId)` | Messages (sentAt, senderDid, status, json) | 338| `dm_outbox` | `localId` | Outgoing messages queue (convoId, payloadJson, attemptCount, retryAt) | 339 340### Smart Folders 341 342| Table | Primary Key | Purpose | 343| -------------------- | --------------------- | ------------------------------------------------------ | 344| `smart_folders` | `folderId` | Folder metadata (name, sortMode, createdAt, updatedAt) | 345| `smart_folder_rules` | `ruleId` | Folder rules (folderId, type, payloadJson) | 346| `smart_folder_items` | `(folderId, postUri)` | Optional materialization (score, insertedAt) | 347 348### Drafts 349 350| Table | Primary Key | Purpose | 351| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | 352| `drafts` | `draftId` | Draft metadata (createdAt, updatedAt, status, text, facetsJson, replyToUri, quoteUri, embedJson) | 353| `draft_media` | `(draftId, localMediaId)` | Draft media (pathOrUri, mime, size, altText, uploadCid, status) | 354 355</details> 356 357## References 358 359- [Bluesky API Documentation](https://docs.bsky.app/) 360- [AT Protocol Specification](https://atproto.com/) 361- [Flutter Documentation](https://flutter.dev/docs) 362 363## Credits 364 365Typography inspiration from [Anisota](https://anisota.net/) by [Dame.is](https://dame.is) ([@dame.is](https://bsky.app/profile/dame.is)). 366 367[Witchsky](https://witchsky.app/) ([code](https://tangled.org/jollywhoppers.com/witchsky.app/))