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/))