# Lazurite
Cross-platform Bluesky client built with Flutter and Dart using Material You (M3) design.
## Features
- Auth + identity resolution
- Home timeline + threads + profiles + search + notifications
- Compose (with media) + likes/reposts/replies
- DMs (read + write): convo list, messages, send, read state
- Local features:
- Smart folders (local-only "feeds" built from cached posts)
- Local post drafts (offline compose; later publish)
### Maybes
- Firehose ingestion, custom feed generator hosting, heavy moderation tooling
## Architecture
### Stack
Flutter + Dart, Drift (SQLite), Dio (networking), Riverpod (state), go_router (navigation)
### Data flow
- Authenticated requests → user's PDS
- Public reads → public.api.bsky.app
- Chat → PDS with service proxy header
### Storage
- Drift tables for posts, profiles, timeline, notifications, DMs,
[smart folders](#smart-folders), and [drafts](#drafts)
- Auth secrets in secure storage (not Drift).
## Local Dev
Use `just` targets:
- `just format` - runs `dart format lib test`
- `just lint` - proxies `flutter analyze`
- `just test` - executes the full `flutter test` suite
- `just gen` - invokes `dart run build_runner build --delete-conflicting-outputs`
- `just check` - performs format + lint + test before commits
For local smoke runs:
```sh
flutter pub get
flutter run -d android
flutter run -d chrome
flutter run -d ios
```
Endpoint Routing & Host Strategy
### Host Selection
Host selection is a first-class concern:
1. **Unauthenticated, public reads** (fast path)
- Base URL: `https://public.api.bsky.app`
- Use for: browsing without login (profiles, public feeds, threads, search)
2. **Authenticated requests** (normal logged-in mode)
- Base URL: the user's PDS (discovered from identity resolution)
- Why: authenticated requests are "usually made to the user's PDS, with automatic service proxying"
3. **Service proxying** (chat + some services)
- Make the request to the user's PDS, and set the service proxy header to target the downstream service
- Bluesky chat endpoints require proxying to `did:web:api.bsky.chat`
### Endpoint Map by Feature
#### Identity + Sessions
| Endpoint | Method | Host | Auth | Notes |
| -------------------------------------- | ------ | ---- | ---- | ------------------------------------------------- |
| `com.atproto.identity.resolveHandle` | GET | PDS | None | Resolve handle → DID |
| `com.atproto.identity.resolveDid` | GET | PDS | None | Resolve DID → DID document |
| `com.atproto.identity.resolveIdentity` | GET | PDS | None | Resolve handle or DID → DID doc + verified handle |
| `com.atproto.server.createSession` | POST | PDS | None | Legacy login (app passwords) |
| `com.atproto.server.getSession` | GET | PDS | User | Check current session |
#### Core Reading
| Endpoint | Method | Host | Auth | Notes |
| ----------------------------- | ------ | ------------- | ----------- | ---------------------------- |
| `app.bsky.feed.getTimeline` | GET | PDS / AppView | User | Home timeline |
| `app.bsky.actor.getProfile` | GET | PDS / AppView | None / User | Actor profile |
| `app.bsky.feed.getAuthorFeed` | GET | PDS / AppView | None | Actor feed (posts + reposts) |
| `app.bsky.feed.getPostThread` | GET | PDS / AppView | None / User | Thread view |
| `app.bsky.feed.getPosts` | GET | PDS / AppView | None | Hydrate posts by AT-URI |
| `app.bsky.feed.searchPosts` | GET | PDS / AppView | None / User | Search posts |
| `app.bsky.feed.getLikes` | GET | PDS / AppView | None | Who liked a post |
| `app.bsky.feed.getRepostedBy` | GET | PDS / AppView | None | Who reposted a post |
| `app.bsky.graph.getFollowers` | GET | PDS / AppView | None | Get followers |
| `app.bsky.graph.getFollows` | GET | PDS / AppView | None | Get follows |
#### Notifications
| Endpoint | Method | Host | Auth | Notes |
| ----------------------------------------- | ------ | ---- | ---- | ------------------ |
| `app.bsky.notification.listNotifications` | GET | PDS | User | List notifications |
| `app.bsky.notification.getUnreadCount` | GET | PDS | User | Unread count |
#### Writing (Posts, Likes, Reposts, Follows)
| Endpoint | Method | Host | Auth | Notes |
| ------------------------------- | ------ | ---- | ---- | ------------------------------- |
| `com.atproto.repo.createRecord` | POST | PDS | User | Create a record (generic write) |
| `com.atproto.repo.putRecord` | POST | PDS | User | Update a record |
| `com.atproto.repo.deleteRecord` | POST | PDS | User | Delete a record |
| `com.atproto.repo.applyWrites` | POST | PDS | User | Batch writes (atomic-ish) |
| `com.atproto.repo.uploadBlob` | POST | PDS | User | Upload media blob |
Collections used with createRecord/putRecord/deleteRecord:
- Post: `app.bsky.feed.post`
- Like: `app.bsky.feed.like`
- Repost: `app.bsky.feed.repost`
- Follow: `app.bsky.graph.follow`
#### DMs (Bluesky Chat via PDS proxy)
| Endpoint | Method | Host | Auth | Proxy | Notes |
| ----------------------------- | ------ | ---- | ---- | ----------------------- | ------------------------- |
| `chat.bsky.convo.listConvos` | GET | PDS | User | `did:web:api.bsky.chat` | Convo list (inbox) |
| `chat.bsky.convo.getMessages` | GET | PDS | User | `did:web:api.bsky.chat` | Fetch messages in a convo |
| `chat.bsky.convo.sendMessage` | POST | PDS | User | `did:web:api.bsky.chat` | Send message |
| `chat.bsky.convo.updateRead` | POST | PDS | User | `did:web:api.bsky.chat` | Mark convo read |
| `chat.bsky.convo.acceptConvo` | POST | PDS | User | `did:web:api.bsky.chat` | Accept convo request |
**Implementation note:** The "proxying header" mechanism is standardized in XRPC HTTP specs; implement it once in Dio as an interceptor/RequestOptions hook, then reuse.
### Local-only Features
#### "Smart" folders
- Definition: locally stored queries over cached timeline/threads + local labels
- Example folder rules: "Unread", "From follows only", "Mentions + keyword", "Media only", "Saved"
- Inputs (network): whatever you choose to cache (timeline/author feed/thread/etc.)
- Output: purely local projection; no server-side "custom feed generator" required
#### Local post drafts
- Stored in Drift only; not published until you call repo.createRecord/applyWrites
Routing
### Why go_router?
- URL-based navigation (deep links, shareable routes, desktop parity)
- Nested navigation with persistent tab state
- Auth gating via redirect()
- Path + query parameters, nested routes, and redirection support
### Router Shape
Use `StatefulShellRoute.indexedStack` for bottom tabs so each tab preserves state (scroll position, nested stacks).
**Tabs (StatefulShellRoute branches):**
- Home
- Search
- Notifications
- DMs
- Profile (Me)
**Rules:**
- Put "detail" routes (thread, profile, convo) as children of the branch where the user is coming from, so the bottom bar stays visible
- Put "global modals" (compose, settings) on the root navigator so they present above tabs
### Route Map
| Path | Description | Notes |
| -------------------- | ------------------- | ------------ |
| `/splash` | (optional) | |
| `/login` | Login screen | |
| `/settings` | Settings screen | |
| `/compose` | Compose modal | Root modal |
| `/drafts` | Local-only list | |
| `/drafts/:draftId` | Edit draft | |
| `/home` | Home tab | |
| `/home/t/:postKey` | Thread from home | |
| `/home/u/:did` | Profile from home | |
| `/search` | Search tab | |
| `/search?q=` | Search results | Query params |
| `/search/t/:postKey` | Thread from search | |
| `/search/u/:did` | Profile from search | |
| `/notifs` | Notifications tab | |
| `/notifs/t/:postKey` | Thread from notifs | |
| `/notifs/u/:did` | Profile from notifs | |
| `/dms` | DMs tab | |
| `/dms/c/:convoId` | Convo detail | |
| `/me` | Profile (me) tab | |
| `/me/u/:did` | Profile view | |
**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=...`).
### Auth Gating + Reactive Redirects (Riverpod)
go_router redirects are not automatically reactive; you must provide a `refreshListenable` if you want auth changes to trigger redirects reliably.
**Pattern (opinionated, reliable):**
- Auth state lives in Riverpod (e.g., AuthController Notifier)
- Create a small Listenable bridge for GoRouter.refreshListenable:
- `ValueNotifier` "tick"
- `ref.listen(authProvider, (_, __) => tick.value++)`
- In `redirect()`, read auth state and return:
- unauthenticated → `/login` for protected routes
- authenticated → redirect away from `/login` to `/home`
**Protected vs public routes:**
- Public allowed: `/home` (read-only), `/search`, viewing threads/profiles
- Auth required: `/notifs`, `/dms`, `/compose` publish, follow/like/repost actions
### Deep Linking (Android/iOS)
- Use go_router URL paths that mirror shareable content
- Configure Android App Links + iOS Universal Links so `https:///...` can open the app to a matching route
- Prefer path params for required IDs; query params for optional filters/search
State Management
### Why Riverpod?
- Dependency injection (Dio clients, DB, repositories)
- View-model state (timeline paging, thread loading, draft autosave, DM outbox)
- Flutter's architecture guide recommends separation of concerns and "Views + ViewModels" with repositories in the data layer
- Riverpod recommends (Async)Notifier/(Async)NotifierProvider for state that changes via user interaction
### Provider Types
| Type | Example | Use Case |
| ----------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
| `Provider` | `Provider` | Singletons / resources (DB, Dio, secure storage, endpoint map) |
| `Provider` | `Provider` | Repositories (data layer) |
| `AsyncNotifierProvider` | `AsyncNotifierProvider` | ViewModels with I/O (network/db) |
| `NotifierProvider` | `NotifierProvider` | ViewModels with sync state (text fields + local draft) |
**Dependencies (singletons / resources):**
- `Provider` (Drift DB)
- `Provider` dioPublic
- `Provider` dioPds
- `Provider` (secure storage-backed)
- `Provider` (single source of routing truth)
**Repositories (data layer):**
- `Provider`
- `Provider`
- `Provider`
- `Provider`
**ViewModels (presentation layer):**
- `AsyncNotifierProvider`
- `AsyncNotifierProvider`
- `NotifierProvider` (sync + autosave)
- `AsyncNotifierProvider`
- `AsyncNotifierProvider`
**Rule of thumb:**
- Use `AsyncNotifier` when you do I/O (network/db) or have "loading/error/data"
- Use `Notifier` for purely local synchronous state (text fields + local draft), while persisting via a repository
### File Placement
```sh
lib/src/app/providers.dart
- global providers for env, dio, db, secure storage, endpoint map
lib/src/infrastructure/...
- pure Dart classes: XrpcClient, interceptors, DAOs (no Riverpod here)
lib/src/features//presentation/_controller.dart
- Notifier/AsyncNotifier implementation
- holds paging cursors, refresh logic, optimistic UI, etc.
lib/src/features//domain/usecases/
- optional: thin usecases called by controllers, especially for complex flows
(publish draft pipeline, DM outbox flush)
```
### Testing Conventions
- Unit test controllers with `ProviderContainer`:
- Override repositories with fakes
- Assert state transitions: loading → data, optimistic updates, retry behavior
- DAO tests run against an in-memory/temporary Drift database
- Network tests mock Dio adapters (or replace XrpcClient in providers)
Database Schema (Drift)
### Principles
- Use stable keys (DID, at:// URIs)
- Store raw JSON payloads for forward compatibility, plus minimal indexed columns
- Keep auth secrets out of Drift; store them in secure storage
### Core Cache Tables
| Table | Primary Key | Purpose |
| ---------------- | -------------------- | -------------------------------------------------------- |
| `accounts` | `did` | User accounts (handle, pdsUrl, active) |
| `profiles` | `did` | Profile data (handle, json, updatedAt) |
| `posts` | `uri` | Post data (cid, authorDid, textPreview, indexedAt, json) |
| `timeline_items` | `(feedKey, postUri)` | Timeline cache (sortKey, cursor) |
| `notifications` | `id` | Notifications (indexedAt, seenAt, json) |
### DMs
| Table | Primary Key | Purpose |
| ------------- | ---------------------- | --------------------------------------------------------------------- |
| `dm_convos` | `convoId` | Conversation metadata (lastMessageAt, unreadCount, muted, json) |
| `dm_members` | `(convoId, memberDid)` | Conversation members |
| `dm_messages` | `(convoId, messageId)` | Messages (sentAt, senderDid, status, json) |
| `dm_outbox` | `localId` | Outgoing messages queue (convoId, payloadJson, attemptCount, retryAt) |
### Smart Folders
| Table | Primary Key | Purpose |
| -------------------- | --------------------- | ------------------------------------------------------ |
| `smart_folders` | `folderId` | Folder metadata (name, sortMode, createdAt, updatedAt) |
| `smart_folder_rules` | `ruleId` | Folder rules (folderId, type, payloadJson) |
| `smart_folder_items` | `(folderId, postUri)` | Optional materialization (score, insertedAt) |
### Drafts
| Table | Primary Key | Purpose |
| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
| `drafts` | `draftId` | Draft metadata (createdAt, updatedAt, status, text, facetsJson, replyToUri, quoteUri, embedJson) |
| `draft_media` | `(draftId, localMediaId)` | Draft media (pathOrUri, mime, size, altText, uploadCid, status) |
## References
- [Bluesky API Documentation](https://docs.bsky.app/)
- [AT Protocol Specification](https://atproto.com/)
- [Flutter Documentation](https://flutter.dev/docs)
## Credits
Typography inspiration from [Anisota](https://anisota.net/) by [Dame.is](https://dame.is) ([@dame.is](https://bsky.app/profile/dame.is)).
[Witchsky](https://witchsky.app/) ([code](https://tangled.org/jollywhoppers.com/witchsky.app/))