mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1# Lazurite Architecture
2
3## Core Principles
4
5### Separation of Concerns
6
7- UI layer (presentation) vs Data layer (infrastructure), with clear boundaries
8- Feature-first organization inside those layers
9 (each feature owns its domain/data/presentation)
10- Reactive data flow using Riverpod providers and Drift streams
11
12## Feature Layer Architecture
13
14All features follow a three-layer architecture for clear separation of concerns:
15
16### Domain Layer (`domain/`)
17
18**Purpose:** Business logic and data models independent of framework implementation.
19
20**Contains:**
21
22- Domain models (plain Dart classes, freezed/json_serializable DTOs)
23- Business rules and validation logic
24- Feature-specific exceptions
25
26**Rules:**
27
28- No Flutter imports (package:flutter)
29- No infrastructure dependencies (Drift, Dio, providers)
30- Pure Dart code that could run in any Dart environment
31- Models represent the problem domain, not API schemas or database tables
32
33### Infrastructure Layer (`infrastructure/`)
34
35**Purpose:** External integrations and data persistence.
36
37**Contains:**
38
39- Repository implementations (API clients, database DAOs)
40- Network request/response handling
41- Drift table definitions and database queries
42- Data mapping between domain models and external schemas
43- Cache management and synchronization logic
44
45**Rules:**
46
47- Implements contracts defined by domain (if using abstract repositories)
48- Handles API-specific details (cursors, pagination, error codes)
49- Manages ownerDid scoping for multi-account isolation
50- Returns domain models, not API DTOs or Drift entities directly
51
52### Application Layer (`application/`)
53
54**Purpose:** State management and coordination between UI and infrastructure.
55
56**Contains:**
57
58- Riverpod providers and notifiers
59- UI state classes (loading, error, data states)
60- Orchestration logic combining multiple repositories
61- Application-level business rules (e.g., sync triggers, cache invalidation)
62
63**Rules:**
64
65- Consumes infrastructure repositories via dependency injection
66- Exposes reactive streams or state for UI consumption
67- No direct Drift queries (delegate to repositories)
68- No UI widgets (widgets belong in presentation/)
69
70### Presentation Layer (`presentation/`)
71
72**Purpose:** UI components and user interaction handling.
73
74**Contains:**
75
76- Screens, pages, and widget trees
77- UI-specific state (form controllers, animation controllers)
78- Navigation logic
79- User input handling and validation
80
81**Rules:**
82
83- Consumes application providers, never repositories directly
84- No business logic beyond UI concerns (visibility, formatting, validation feedback)
85- No direct database or network access
86
87### Feature Organization Example
88
89```sh
90features
91 └── feeds
92 ├── domain # Domain models
93 │ ├── feed.dart
94 │ └── feed_post.dart
95 ├── infrastructure # API + caches
96 │ ├── feed_repository.dart
97 │ └── feed_content_repository.dart
98 ├── application # Riverpod state
99 │ ├── feed_notifier.dart
100 │ └── feed_sync_controller.dart
101 └── presentation # UI/Widgets
102 ├── feed_screen.dart
103 └── feed_post_card.dart
104
105```
106
107### Migration Strategy
108
109Features missing layers should be refactored incrementally:
110
1111. Extract domain models from presentation or infrastructure
1122. Move API/database logic into repositories
1133. Create application notifiers to coordinate infrastructure
1144. Update presentation to consume application providers only
115
116### Cross-Cutting Infrastructure
117
118Some infrastructure components serve multiple features and live in `lib/src/infrastructure/`:
119
120- **Auth** (`infrastructure/auth/`) - OAuth, session management, token refresh
121- **Network** (`infrastructure/network/`) - Dio clients, XRPC, endpoint routing
122- **Database** (`infrastructure/db/`) - Drift setup, shared DAOs, migrations
123- **Identity** (`infrastructure/identity/`) - DID resolution, handle verification
124
125Features consume these via dependency injection through application providers.
126
127### ATProto Best Practices
128
129- Cursor-based pagination everywhere (avoid OFFSET paging for feeds)
130- DID + at:// URIs internally; handles are user-facing only
131- Service proxying via `atproto-proxy` header for DMs and specialized services
132
133### Data Persistence
134
135- Drift for relational data with reactive queries
136- Secure storage (flutter_secure_storage) for tokens/keys, never in Drift
137- Offline-first with optimistic updates and sync queues
138
139### Multi-Account Data Isolation
140
141All user-scoped data is keyed by `ownerDid` to prevent data bleeding between accounts:
142
143**Scoped Tables:**
144
145- `SavedFeeds` - User's saved feed generators
146- `FeedContentItems` - Cached posts from feeds
147- `FeedCursors` - Pagination cursors per feed
148- `Notifications` - User notifications
149- `BlueskyPreferences` - Content moderation settings
150- `DmConvos` / `DmMessages` - Direct message conversations
151
152**Implementation Pattern:**
153
154DAOs accept `ownerDid` as a required parameter and filter all queries by it.
155
156```dart
157Stream<List<T>> watchData(String ownerDid) =>
158 (select(table)..where((t) => t.ownerDid.equals(ownerDid))).watch();
159```
160
161Notifiers resolve `ownerDid` from the current auth state, defaulting to `'anonymous'`
162for unauthenticated users viewing public content (e.g., Discover feed).
163
164```dart
165final ownerDid = (authState is AuthStateAuthenticated)
166 ? authState.session.did
167 : 'anonymous';
168```
169
170**Logout Behavior:**
171User data is NOT wiped on logout. Instead, `ownerDid` filtering ensures the next
172logged-in user only sees their own data. This allows fast account switching while
173maintaining isolation.
174
175## Feed Architecture
176
177The feed system manages both feed metadata and content through two coordinated
178repositories.
179
180### Feed Metadata (`FeedRepository`)
181
182**Purpose:** Manages information about feed generators (algorithms/sources).
183
184**Location:** `lib/src/features/feeds/infrastructure/feed_repository.dart`
185
186**Responsibilities:**
187
188- Syncing saved feeds from user preferences (`app.bsky.actor.getPreferences`)
189- Caching feed metadata (displayName, avatar, creator, likeCount)
190- Managing pinned feeds for the feed selector UI
191- Discovering trending feeds
192- Handling offline-first optimistic updates with preference sync queue
193
194**Data Model:** `SavedFeeds` table
195
196- uri, displayName, description, avatar, creatorDid, likeCount, sortOrder, isPinned,
197 lastSynced
198
199**Feed URI Constants:**
200
201- `kHomeFeedUri = 'home'` - Following feed
202- `kDiscoverFeedUri` - Discover feed generator URI
203- `kForYouFeedUri` - For You feed generator URI
204
205### Feed Content (`FeedContentRepository`)
206
207**Purpose:** Manages cached post content from feeds.
208
209**Location:** `lib/src/features/feeds/infrastructure/feed_content_repository.dart`
210
211**Responsibilities:**
212
213- Fetching feed content (`app.bsky.feed.getTimeline`, `app.bsky.feed.getFeed`)
214- Caching posts, profiles, and feed content items
215- Managing cursors for pagination
216- Providing reactive streams for UI updates
217
218**Data Models:**
219
220- `Posts` table - post content and engagement metrics
221- `Profiles` table - author profile data
222- `FeedContentItems` table - feed-to-post relationships with sortKey and reason
223 (repost info)
224- `FeedCursors` table - pagination cursors per feed
225
226**Data Flow:**
227
228```text
229FeedContentRepository.fetchAndCacheFeed()
230 ↓
231FeedContentDao.insertFeedContentBatch()
232 ↓
233Drift: Posts + Profiles + FeedContentItems
234 ↓
235FeedContentDao.watchFeedContent() → Stream<List<FeedPost>>
236 ↓
237FeedContentNotifier → UI
238```
239
240### FeedPost Data Structure
241
242Simplified view combining data from multiple tables:
243
244```dart
245class FeedPost {
246 final Post post; // Post content
247 final Profile author; // Author profile
248 final String? reason; // Feed-specific metadata (e.g., repost info)
249}
250```
251
252The `reason` field contains JSON describing why the post appears in the feed
253(e.g., "Reposted by @user").
254
255## Network Architecture
256
257### Host Routing
258
259**Two Dio instances:**
260
261- `dioPublic` - baseUrl: `https://public.api.bsky.app` (unauthenticated calls)
262- `dioPds` - baseUrl: user's PDS URL (authenticated calls with automatic proxying)
263
264**Endpoint Registry:**
265Never "guess" where to send a call at runtime; encode routing in an endpoint map.
266
267**Read-After-Write:**
268PDS can patch AppView reads for consistency after writes.
269
270### Service Proxying (DMs)
271
272Chat requests use the `atproto-proxy` header:
273
274- Header: `atproto-proxy: did:web:api.bsky.chat#bsky_chat`
275- Requests go to user's PDS and are proxied to the chat service
276- Must use PDS proxy; direct calls to public.api.bsky.app will fail
277
278## Authentication
279
280### OAuth Implementation
281
282**Requirements:**
283
284- DPoP (Demonstrating Proof-of-Possession)
285- PAR (Pushed Authorization Request) in initial auth request
286- Loopback server for callback handling
287
288### Token Refresh Strategy
289
2901. Reactive Refresh (401): When a request returns 401 Unauthorized, attempt token
291 refresh and retry
2922. Proactive Refresh: When token is within 5 minutes of expiration, refresh before
293 the request
2943. Session Invalidation (400): When server returns `InvalidToken` or `ExpiredToken`,
295 clear session entirely
296
297**Token Lifecycle:**
298
299```text
300[Created] ──────────> [Near Expiration] ──────> [Expired]
301 (5 min before)
302 ↓ ↓ ↓
303 Normal Proactive 401 Retry
304 Requests Refresh or Invalidate
305```
306
307## Cache Management
308
309### Cache Invalidation Triggers
310
3111. Session Logout: Clears session storage, sets AuthState.unauthenticated
3122. Session Invalidation (InvalidToken): Clears all cached content + logout
3133. Stale Feed Cleanup: `FeedContentCleanupController` removes items not updated in 7
314 days
315
316## Application Lifecycle
317
318Managed via `AppLifecycle` provider and direct `WidgetsBindingObserver` where critical
319responsiveness is needed (e.g., autosave).
320
321### Lifecycle State Flow
322
323```mermaid
324graph TD
325 A[App Launched] --> B[Resumed/Foreground]
326 B -->|User minimizes/switches| C[Inactive]
327 C -->|OS pauses execution| D[Paused/Background]
328 D -->|User returns| B
329 D -->|OS kills app| E[Detached/Terminated]
330
331 subgraph "On Resume"
332 B -.-> F[Feed Sync]
333 B -.-> G[Preference Sync]
334 end
335
336 subgraph "On Pause/Inactive"
337 C -.-> H[Composer Autosave]
338 D -.-> H
339 end
340```
341
342### Implementation Strategy
343
3441. **Global Observers**: `AppLifecycle` provider (Riverpod) for reactive state
345 management.
346 - **Feed Sync**: `FeedSyncController` listens for `AppLifecycleState.resumed` to
347 trigger `feedRepository.syncOnResume()`, ensuring content is fresh when the user
348 returns.
349 - **Preference Sync**: `PreferenceSyncController` fetches remote preferences and
350 processes the background sync queue on `resumed`.
3512. **Feature-Specific Implementation**:
352 - **Composer Autosave**: Uses `WidgetsBindingObserver` directly in `ComposerScreen`
353 state to trigger `forceSave` immediately on `paused` or `inactive` signals.
354 This bypasses the slight delay of provider updates to ensure drafts are saved before
355 the OS can kill the process.
356
357## Feature Implementation Patterns
358
359### Draft Publishing Pipeline
360
3611. If draft has media files not yet uploaded → `uploadBlob()`
3622. Construct `app.bsky.feed.post` record with blob references
3633. `createRecord(collection: app.bsky.feed.post)`
3644. On success: mark draft as published (or delete)
365
366**Important:** Blobs are "temporary" until referenced by a record
367(time window constraint).
368
369### Smart Folders (Future)
370
371**MVP Rule Types:**
372
373- Author allow/deny (DID list)
374- Keyword include/exclude (post text)
375- Has media / has link
376- Replies only / exclude replies
377- Bookmarked only
378- "Unread only" (local read-state)
379
380**Execution Strategy:**
381
382- Start non-materialized: `SELECT posts WHERE <rules> ORDER BY indexedAt DESC`
383- Upgrade to materialized if needed: On cache insert, evaluate rules and populate folder
384 items
385
386## Testing Strategy
387
388- Unit tests for business logic
389- Widget tests for UI components
390- Integration tests for repository/DAO interactions
391- Target: >95% code coverage
392
393## References
394
395- [Read-After-Write](https://docs.bsky.app/docs/advanced-guides/read-after-write)
396- [Handle Resolution](https://atproto.com/specs/handle)
397- [XRPC Specification](https://atproto.com/specs/xrpc)
398- [Bluesky HTTP Reference](https://docs.bsky.app/docs/category/http-reference)
399- [Chat Service Issue #2775](https://github.com/bluesky-social/atproto/issues/2775)