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() 230231FeedContentDao.insertFeedContentBatch() 232233Drift: Posts + Profiles + FeedContentItems 234235FeedContentDao.watchFeedContent() → Stream<List<FeedPost>> 236237FeedContentNotifier → 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)