mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: constellation client with settings

+1016 -22
+133 -5
docs/specs/phase-5.md
··· 1 1 --- 2 2 title: Phase 5 Spec 3 - updated: 2026-03-25 3 + updated: 2026-03-31 4 4 --- 5 5 6 6 ## Feature Parity 7 7 8 - Three new endpoint integrations to round out UI coverage. 8 + Three new endpoint integrations to round out UI coverage, plus two Constellation-powered features. 9 9 10 10 --- 11 11 ··· 18 18 19 19 | Param | Type | Required | Default | Notes | 20 20 |----------|--------|----------|---------|-------------------------------| 21 - | `q` | string | yes | — | Lucene-style query | 21 + | `q` | string | yes | - | Lucene-style query | 22 22 | `limit` | int | no | 25 | 1–100 | 23 - | `cursor` | string | no | — | Pagination cursor | 23 + | `cursor` | string | no | - | Pagination cursor | 24 24 25 25 **Response:** 26 26 ··· 65 65 } 66 66 ``` 67 67 68 - No pagination — returns all suggestions in one response. 68 + No pagination - returns all suggestions in one response. 69 69 70 70 **SDK:** `bluesky.graph.getSuggestedFollowsByActor(actor:)` 71 71 → `XRPCResponse<GraphGetSuggestedFollowsByActorOutput>` ··· 104 104 Show `canUpload` status and any server `message`. Fetch on screen load; show 105 105 loading indicator while fetching. If the endpoint returns an error or `canUpload` 106 106 is false, show the reason. 107 + 108 + --- 109 + 110 + ### 4. Profile Context (Constellation) 111 + 112 + Social context for any account powered by [Constellation](https://constellation.microcosm.blue/) - a public AT Protocol backlink index. No auth required; only a `User-Agent` header. 113 + 114 + **Design philosophy** (carried from lazurite-desktop diagnostics): 115 + 116 + - Inform, don't alarm. Present data neutrally. 117 + - No composite risk scores. Show the data; let the user interpret it. 118 + - Context over counts. Prefer showing _what kind_ of lists over _how many_. 119 + - Respect the viewed account. Default to aggregate summaries; expand to specifics on request. 120 + 121 + #### Constellation Client 122 + 123 + A thin HTTP client targeting a configurable Constellation instance (default: `https://constellation.microcosm.blue`). User-configurable via Settings to support self-hosted instances. Timeout: 10 seconds. 124 + 125 + All endpoints use XRPC format at `{base}/xrpc/{endpoint}` with query parameters. 126 + 127 + #### 4a. Blocked By (incoming blocks) 128 + 129 + **Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinksCount` 130 + **Purpose:** Count of accounts that have blocked this user. 131 + 132 + | Param | Type | Required | Notes | 133 + |----------|--------|----------|------------------------------------| 134 + | `subject`| string | yes | Target DID | 135 + | `source` | string | yes | `app.bsky.graph.block:subject` | 136 + 137 + **Response:** `{ "total": int }` 138 + 139 + **Detail list endpoint:** `GET /xrpc/blue.microcosm.links.getDistinct` 140 + 141 + | Param | Type | Required | Default | Notes | 142 + |----------|--------|----------|---------|------------------------------------| 143 + | `subject`| string | yes | - | Target DID | 144 + | `source` | string | yes | - | `app.bsky.graph.block:subject` | 145 + | `limit` | int | no | 16 | Max 100 | 146 + | `cursor` | string | no | - | Pagination cursor | 147 + 148 + **Response:** `{ "total": int, "dids": string[], "cursor": string? }` 149 + 150 + Returned DIDs are hydrated via `bluesky.actor.getProfiles(actors:)` (batch, max 25 per call) to show profile cards. 151 + 152 + #### 4b. Users Blocked (outgoing blocks) 153 + 154 + **Endpoint:** `GET /xrpc/com.atproto.repo.listRecords` (AT Protocol, not Constellation) 155 + 156 + | Param | Type | Required | Default | Notes | 157 + |-------------|--------|----------|---------|-------------------------------| 158 + | `repo` | string | yes | - | Actor DID | 159 + | `collection`| string | yes | - | `app.bsky.graph.block` | 160 + | `limit` | int | no | 50 | Max 100 | 161 + | `cursor` | string | no | - | Pagination cursor | 162 + 163 + **Response:** `{ "records": Record[], "cursor": string? }` 164 + 165 + Each record's `value.subject` is the blocked DID. Hydrate via `getProfiles`. 166 + 167 + **Note:** Outgoing blocks are only readable from the actor's own repo. For other users' profiles, this tab shows only the count from the actor's public repo listing (if accessible) or is hidden entirely if the repo restricts reads. 168 + 169 + #### 4c. Lists On 170 + 171 + **Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinks` 172 + 173 + | Param | Type | Required | Default | Notes | 174 + |----------|--------|----------|---------|-----------------------------------------| 175 + | `subject`| string | yes | - | Target DID | 176 + | `source` | string | yes | - | `app.bsky.graph.listitem:subject` | 177 + | `limit` | int | no | 16 | Max 100 | 178 + | `cursor` | string | no | - | Pagination cursor | 179 + 180 + **Response:** 181 + 182 + ```json 183 + { 184 + "total": "int", 185 + "linking_records": [{ "did": "string", "collection": "string", "rkey": "string" }], 186 + "cursor": "string?" 187 + } 188 + ``` 189 + 190 + Each backlink record represents a list item. The owning list AT-URI is derived as `at://{record.did}/app.bsky.graph.list/{rkey-of-list}`. Since backlinks only give us the listitem record, we need to resolve the parent list. Two approaches: 191 + 192 + 1. **getManyToMany** (preferred): `GET /xrpc/blue.microcosm.links.getManyToMany` with `source=app.bsky.graph.listitem:subject` and `pathToOther=list` returns items grouped by their parent list URI. Each item has `otherSubject` (the list AT-URI). 193 + 2. **Fallback**: fetch each listitem record via `com.atproto.repo.getRecord` to read its `list` field, then hydrate lists via `bluesky.graph.getList`. 194 + 195 + Hydrate list metadata via `bluesky.graph.getList(list:)` to show name, purpose, owner, member count. 196 + 197 + #### Profile Context UI 198 + 199 + **Entry point:** New "Context" item in the profile screen's overflow menu (PopupMenuButton / three-dot menu). Navigates to a dedicated full screen - not a tab on the profile, since this data is sought intentionally, not browsed casually. 200 + 201 + **Route:** `/profile-context?did={DID}` 202 + 203 + **Screen layout:** 204 + 205 + - `AppBar` with title "Profile Context" and the user's handle as subtitle 206 + - Three-tab `TabBar`: **Blocked By** | **Blocking** | **Lists** 207 + - Each tab is a paginated list with pull-to-refresh 208 + 209 + **Blocked By tab:** 210 + 211 + - Header row: total count (from `getBacklinksCount`) displayed prominently 212 + - "Show accounts" expand button - on tap, fetches DIDs via `getDistinct` and hydrates profiles 213 + - Profile tiles: avatar, display name, handle. Tap → navigate to profile 214 + - Pagination via cursor (infinite scroll) 215 + - Contextualizing note: _"Blocks are a normal part of social media. This data is public on the AT Protocol."_ 216 + 217 + **Blocking tab:** 218 + 219 + - Same layout as Blocked By but sourced from `listRecords` 220 + - Only available when viewing own profile or if the repo is publicly readable 221 + - When unavailable: show explanatory text 222 + 223 + **Lists tab:** 224 + 225 + - List cards: name, owner handle, purpose badge (curate/modlist/reference), member count, description snippet 226 + - Grouped by purpose (curation first, then moderation, then reference) 227 + - Tap → navigate to list detail screen (`/list?uri=`) 228 + - Pagination via cursor 229 + 230 + **States:** 231 + 232 + - Loading: skeleton shimmer matching card dimensions 233 + - Empty: per-tab contextual empty state (e.g., "Not on any lists" / "No blocks found") 234 + - Error: inline retry button per tab, not full-screen error
+123
docs/specs/phase-6.md
··· 1 + --- 2 + title: Phase 6 Spec 3 + updated: 2026-03-31 4 + --- 5 + 6 + ## Social Graph Visualization 7 + 8 + A force-directed graph visualization showing a user's social connections. Inspired by [Skircle](https://skircle.me) - lets people explore their network as an interactive tension graph. 9 + 10 + ### Data Sources 11 + 12 + The graph is built from three relationship sets for a target DID: 13 + 14 + **Follows (outgoing):** `bluesky.graph.getFollows(actor:, limit:, cursor:)` 15 + → Paginated `ProfileView[]`. Fetch up to a configurable cap (default: 200) to keep rendering performant. 16 + 17 + **Followers (incoming):** `bluesky.graph.getFollowers(actor:, limit:, cursor:)` 18 + → Paginated `ProfileView[]`. Same cap. 19 + 20 + **Mutual detection:** Intersect the two sets by DID. Mutuals get a distinct edge style (thicker, accent-colored). 21 + 22 + **Constellation backlinks for extended context:** 23 + 24 + - `getDistinct` with `source=app.bsky.graph.block:subject` to identify blocked/blocking edges (rendered as dashed/muted) 25 + - `getBacklinksCount` for aggregate stats shown in the info card 26 + 27 + ### Graph Model 28 + 29 + ```dart 30 + GraphNode { 31 + did: String 32 + handle: String 33 + displayName: String? 34 + avatarUrl: String? 35 + bannerUrl: String? // for the detail card 36 + pdsHost: String // extracted from DID document or handle resolution 37 + relationship: enum { mutual, following, follower } 38 + } 39 + 40 + GraphEdge { 41 + source: DID 42 + target: DID 43 + type: enum { mutual, follows, followedBy } 44 + weight: double // mutual = 1.0, one-way = 0.5 45 + } 46 + ``` 47 + 48 + ### Force-Directed Layout 49 + 50 + Use a velocity Verlet integration loop on a `CustomPainter` canvas (no external package dependency): 51 + 52 + - **Center node:** the target user, pinned at canvas center 53 + - **Charge repulsion:** all nodes repel (Barnes-Hut approximation for O(n log n)) 54 + - **Spring attraction:** connected nodes attract along edges; mutuals have stronger spring constant 55 + - **Edge rendering:** straight lines, color-coded by type (mutual = accent, one-way = muted) 56 + - **Node rendering:** circular avatar with border ring colored by relationship type 57 + - **Tick loop:** driven by `Ticker` (vsync), capped at 60fps, with cooling - simulation freezes after convergence 58 + 59 + **Interaction:** 60 + 61 + - Pan & pinch-zoom via `InteractiveViewer` wrapping the canvas 62 + - Tap node → show user info card (overlay, not navigation) 63 + - Drag node → pin it in place; release to unpin 64 + 65 + **Performance constraints:** 66 + 67 + - Cap total nodes at ~250 to stay smooth on mid-range devices 68 + - Avatars loaded lazily as `ImageProvider` textures, with placeholder circle until loaded 69 + - Simulation auto-pauses when graph settles (kinetic energy below threshold) 70 + 71 + ### User Info Card (overlay) 72 + 73 + Shown as a `Material` card overlaying the graph canvas when a node is tapped. Positioned near the tapped node (clamped to viewport). 74 + 75 + **Layout:** 76 + 77 + - Banner image (cover photo) as card header (120px, fallback: gradient) 78 + - Avatar circle overlapping banner bottom-left (56px) 79 + - Display name (bold) + handle (secondary) 80 + - DID (monospace, truncated with copy button) 81 + - PDS host (e.g., `bsky.network`, derived from DID doc `#atproto_pds` service endpoint) 82 + - "View Profile" button → navigates to `/profile?did={DID}` 83 + 84 + **Dismiss:** tap outside card, swipe down, or tap X button. 85 + 86 + ### PDS Resolution 87 + 88 + To show the PDS host in the info card, resolve the DID document: 89 + 90 + **For `did:plc`:** `GET https://plc.directory/{did}` 91 + → Response includes `service` array; find entry with `id: "#atproto_pds"`, extract `serviceEndpoint` host. 92 + 93 + **For `did:web`:** `GET https://{handle}/.well-known/did.json` 94 + → Same structure. 95 + 96 + Cache resolved PDS hosts in memory (Map<DID, String>) for the session - DID docs rarely change. 97 + 98 + ### Screen & Navigation 99 + 100 + **Route:** `/social-graph?did={DID}` 101 + 102 + **Entry point:** New "Social Graph" item in the profile screen's overflow menu, below "Profile Context". Available for all profiles (own and others). 103 + 104 + **Screen layout:** 105 + 106 + - `AppBar` with title "Social Graph" and handle subtitle 107 + - Full-bleed canvas below 108 + - Floating legend chip row at bottom: colored dots with labels (Mutual / Following / Follower) 109 + - Loading state: centered spinner with "Building graph..." text while fetching follows/followers 110 + - Error state: retry button 111 + 112 + **Progressive loading:** Start rendering as soon as first page of follows arrives. Add nodes incrementally as more pages load - the force simulation naturally incorporates new nodes. 113 + 114 + ### Package Considerations 115 + 116 + No external graph package. Implement with: 117 + 118 + - `CustomPainter` for rendering 119 + - `GestureDetector` / `InteractiveViewer` for interaction 120 + - `Ticker` for animation loop 121 + - `dart:ui` `Canvas` for drawing circles, lines, images 122 + 123 + This avoids dependency risk and gives full control over the mobile-optimized rendering pipeline.
+66 -17
docs/tasks/phase-5.md
··· 1 1 --- 2 2 title: Phase 5 Task Breakdown 3 - updated: 2026-03-25 3 + updated: 2026-03-31 4 4 --- 5 5 6 6 # Phase 5 Milestones 7 7 8 - ## M20 — Starter Pack Search 8 + ## M20 - Starter Pack Search 9 9 10 10 ### Core 11 11 12 - - [ ] `SearchRepository.searchStarterPacks()` — call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 12 + - [ ] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 13 13 - [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension 14 14 15 15 ### Cubit 16 16 17 - - [ ] `SearchBloc` — handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 - - [ ] `SearchState` — add `starterPacks` list and `starterPacksCursor` fields 17 + - [ ] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 + - [ ] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields 19 19 20 20 ### UI 21 21 22 - - [ ] Search screen UI — add third "Starter Packs" tab pill in `_buildTab` row 23 - - [ ] Starter pack result tile widget — show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 22 + - [ ] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row 23 + - [ ] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 24 24 - [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) 25 25 - [ ] Infinite scroll pagination for starter packs tab 26 26 ··· 29 29 - [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 30 30 - [ ] Widget tests: third tab renders, results display, empty state, tap navigation 31 31 32 - ## M21 — Suggested Follows Sheet 32 + ## M21 - Suggested Follows Sheet 33 33 34 34 ### Core 35 35 36 - - [ ] `ProfileRepository.getSuggestedFollows()` — call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 36 + - [ ] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 37 37 38 38 ### Cubit 39 39 40 - - [ ] `SuggestedFollowsCubit` — `load(actor:)` fetches suggestions, exposes loaded/loading/error states 40 + - [ ] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states 41 41 42 42 ### UI 43 43 44 - - [ ] Suggested follows sheet widget — `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 - - [ ] Profile screen overflow menu — add "Suggested Follows" `ListTile` entry; hide when viewing own profile 44 + - [ ] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 + - [ ] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile 46 46 - [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` 47 47 - [ ] Tap profile tile → pop sheet, navigate to profile screen 48 48 - [ ] Empty state when no suggestions returned ··· 52 52 - [ ] Unit tests: repository method, cubit state transitions 53 53 - [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 54 54 55 - ## M22 — Video Upload Limits 55 + ## M22 - Video Upload Limits 56 56 57 57 ### Core 58 58 59 - - [ ] `VideoRepository` (or extend settings repository) — `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 59 + - [ ] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 60 60 61 61 ### Cubit 62 62 63 - - [ ] `VideoUploadLimitsCubit` — fetch on init, expose `canUpload`, remaining counts, message/error 63 + - [ ] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error 64 64 65 65 ### UI 66 66 67 - - [ ] Settings screen — new tile in Account section: "Video Upload Limits" 68 - - [ ] Tile UI — show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 67 + - [ ] Settings screen - new tile in Account section: "Video Upload Limits" 68 + - [ ] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 69 69 - [ ] Loading state while fetching, error state if request fails 70 70 - [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false 71 71 ··· 73 73 74 74 - [ ] Unit tests: repository method, cubit state transitions and formatting 75 75 - [ ] Widget tests: tile renders limits, loading indicator, error state, message display 76 + 77 + ## M23 - Profile Context (Constellation) 78 + 79 + ### Core - Constellation Client 80 + 81 + - [x] `ConstellationClient` - thin HTTP client (`http` package) targeting configurable base URL (default `https://constellation.microcosm.blue`), 10s timeout, `User-Agent: lazurite` 82 + - [x] `Settings` - add `constellation_url` key with default value; expose in Settings screen under "Advanced" 83 + - [x] `getBacklinksCount(subject, source)` → `int` total 84 + - [x] `getDistinct(subject, source, {limit, cursor})` → `({int total, List<String> dids, String? cursor})` 85 + - [x] `getBacklinks(subject, source, {limit, cursor})` → `({int total, List<ConstellationLinkRecord> records, String? cursor})` 86 + - [x] `getManyToMany(subject, source, pathToOther, {limit, cursor})` → `({List<ManyToManyItem> items, String? cursor})` 87 + - [x] `ConstellationLinkRecord` model - `did`, `collection`, `rkey` 88 + - [x] `ManyToManyItem` model - `linkRecord: ConstellationLinkRecord`, `otherSubject: String` 89 + 90 + ### Core - Profile Context Repository 91 + 92 + - [ ] `ProfileContextRepository` - depends on `ConstellationClient` + `Bluesky` 93 + - [ ] `getBlockedByCount(did)` - calls `getBacklinksCount(did, 'app.bsky.graph.block:subject')` 94 + - [ ] `getBlockedByProfiles(did, {cursor})` - calls `getDistinct`, hydrates DIDs via `bluesky.actor.getProfiles` (batched 25), returns `({List<ProfileView> profiles, String? cursor, int total})` 95 + - [ ] `getBlockingProfiles(did, {cursor})` - calls `com.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block')`, extracts subject DIDs, hydrates via `getProfiles`, returns same shape 96 + - [ ] `getListsOn(did, {cursor})` - calls `getManyToMany(did, 'app.bsky.graph.listitem:subject', 'list')`, derives list AT-URIs from `otherSubject`, hydrates via `bluesky.graph.getList`, returns `({List<ListView> lists, String? cursor, int total})` 97 + 98 + ### Cubit 99 + 100 + - [ ] `ProfileContextCubit` - manages tab state, loads counts on init for all three tabs 101 + - [ ] `ProfileContextState` - fields: `blockedByCount`, `blockingCount`, `listsOnCount`, per-tab `status` (initial/loading/loaded/error), per-tab item list + cursor 102 + - [ ] `loadBlockedBy({cursor})` - fetches page of blocked-by profiles, appends to state 103 + - [ ] `loadBlocking({cursor})` - fetches page of blocking profiles, appends to state 104 + - [ ] `loadListsOn({cursor})` - fetches page of lists, appends to state 105 + - [ ] Handle own-profile vs other-profile: blocking tab only available for own profile 106 + 107 + ### UI 108 + 109 + - [ ] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) 110 + - [ ] Route: `/profile-context?did={DID}` in `app_router.dart` 111 + - [ ] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit 112 + - [ ] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text 113 + - [ ] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles 114 + - [ ] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` 115 + - [ ] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry 116 + - [ ] Pull-to-refresh per tab 117 + - [ ] Infinite scroll pagination per tab 118 + 119 + ### Tests 120 + 121 + - [ ] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction 122 + - [ ] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough 123 + - [ ] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending 124 + - [ ] Widget tests: screen renders 3 tabs, blocked-by count + expand, profile tiles render and navigate, list cards render and navigate, empty states, error + retry, blocking tab hidden for non-own profiles
+81
docs/tasks/phase-6.md
··· 1 + --- 2 + title: Phase 6 Task Breakdown 3 + updated: 2026-03-31 4 + --- 5 + 6 + # Phase 6 Milestones 7 + 8 + ## M25 - Social Graph Visualization 9 + 10 + ### Core - Graph Data 11 + 12 + - [ ] `SocialGraphRepository` - depends on `Bluesky` & `ConstellationClient` 13 + - [ ] `getFollows(did, {limit, cursor})` - wraps `bluesky.graph.getFollows`, returns `({List<ProfileView> profiles, String? cursor})` 14 + - [ ] `getFollowers(did, {limit, cursor})` - wraps `bluesky.graph.getFollowers`, returns same shape 15 + - [ ] `fetchGraphData(did, {maxNodes: 200})` - fetches follows + followers up to cap, computes mutual set by DID intersection, returns `GraphData` 16 + - [ ] `GraphNode` model - `did`, `handle`, `displayName`, `avatarUrl`, `bannerUrl`, `relationship` (enum: mutual/following/follower) 17 + - [ ] `GraphEdge` model - `sourceDid`, `targetDid`, `type` (enum: mutual/follows/followedBy), `weight` 18 + - [ ] `GraphData` model - `centerDid`, `List<GraphNode> nodes`, `List<GraphEdge> edges` 19 + 20 + ### Core - PDS Resolution 21 + 22 + - [ ] `PdsResolver` - resolves DID → PDS host 23 + - [ ] `did:plc` resolution - `GET https://plc.directory/{did}`, parse `service` array for `#atproto_pds` entry, extract host from `serviceEndpoint` 24 + - [ ] `did:web` resolution - `GET https://{identifier}/.well-known/did.json`, same parsing 25 + - [ ] In-memory cache (`Map<String, String>`) for resolved PDS hosts, per session 26 + 27 + ### Core - Force-Directed Layout Engine 28 + 29 + - [ ] `ForceSimulation` class - velocity Verlet integration, configurable parameters 30 + - [ ] Charge repulsion force - Barnes-Hut quadtree approximation for O(n log n) 31 + - [ ] Spring attraction force - edge-based, stronger constant for mutual edges 32 + - [ ] Center gravity - gentle pull toward canvas center to prevent drift 33 + - [ ] Cooling schedule - alpha decay, auto-pause when kinetic energy < threshold 34 + - [ ] `SimulationNode` - position (`Offset`), velocity, pinned flag, reference to `GraphNode` 35 + - [ ] `tick()` method - single simulation step, returns whether simulation is still active 36 + 37 + ### Cubit 38 + 39 + - [ ] `SocialGraphCubit` - `load(did)` fetches graph data, initializes simulation 40 + - [ ] `SocialGraphState` - fields: `status` (loading/loaded/error), `graphData`, `selectedNode`, `simulationActive` 41 + - [ ] `selectNode(did)` - sets selected node for info card display 42 + - [ ] `dismissCard()` - clears selected node 43 + - [ ] `pinNode(did, offset)` / `unpinNode(did)` - for drag interaction 44 + 45 + ### UI - Graph Canvas 46 + 47 + - [ ] `SocialGraphScreen` - route `/social-graph?did={DID}`, `AppBar` + full-bleed canvas 48 + - [ ] `GraphPainter extends CustomPainter` - renders edges (lines, color-coded), nodes (avatar circles with relationship-colored borders) 49 + - [ ] `InteractiveViewer` wrapping `CustomPaint` for pan + pinch-zoom 50 + - [ ] `Ticker`-driven animation loop - calls `ForceSimulation.tick()`, triggers repaint via `notifyListeners` 51 + - [ ] Lazy avatar loading - `ImageProvider` textures, placeholder colored circle until loaded 52 + - [ ] Tap detection - hit-test nodes by distance from touch point, emit `selectNode` 53 + - [ ] Drag detection - `GestureDetector` on nodes for pin/unpin 54 + - [ ] Floating legend row - colored dots: Mutual / Following / Follower 55 + - [ ] Loading state - centered spinner + "Building graph..." 56 + - [ ] Error state - retry button 57 + - [ ] Progressive loading - start rendering when first follows page arrives, add nodes as more pages load 58 + 59 + ### UI - Info Card Overlay 60 + 61 + - [ ] `GraphInfoCard` widget - `Material` card positioned near tapped node (clamped to viewport bounds) 62 + - [ ] Banner image header (120px, gradient fallback) 63 + - [ ] Avatar circle (56px) overlapping banner 64 + - [ ] Display name + handle 65 + - [ ] DID row - monospace, truncated, copy-to-clipboard button 66 + - [ ] PDS host - resolved via `PdsResolver`, loading indicator until resolved 67 + - [ ] "View Profile" `FilledButton` → pop card, navigate to `/profile?did={DID}` 68 + - [ ] Dismiss: tap outside, swipe down, or X button 69 + 70 + ### UI - Entry Point 71 + 72 + - [ ] Profile screen overflow menu - add "Social Graph" entry below "Profile Context" 73 + - [ ] Route: `/social-graph?did={DID}` in `app_router.dart` 74 + 75 + ### Tests 76 + 77 + - [ ] Unit tests: `SocialGraphRepository` - follows/followers fetching, cap enforcement, mutual detection 78 + - [ ] Unit tests: `PdsResolver` - `did:plc` and `did:web` parsing, cache hit, error handling 79 + - [ ] Unit tests: `ForceSimulation` - convergence, node pinning, cooling, edge weight effect 80 + - [ ] Unit tests: `SocialGraphCubit` - state transitions, node selection, progressive loading 81 + - [ ] Widget tests: screen renders graph canvas, legend chips, info card shows on tap with correct fields, "View Profile" navigates, loading/error states
+139
lib/core/network/constellation_client.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:http/http.dart' as http; 4 + 5 + const String _defaultBaseUrl = 'https://constellation.microcosm.blue'; 6 + const Duration _kTimeout = Duration(seconds: 10); 7 + 8 + class ConstellationLinkRecord { 9 + const ConstellationLinkRecord({required this.did, required this.collection, required this.rkey}); 10 + 11 + final String did; 12 + final String collection; 13 + final String rkey; 14 + 15 + factory ConstellationLinkRecord.fromJson(Map<String, dynamic> json) { 16 + return ConstellationLinkRecord( 17 + did: json['did'] as String, 18 + collection: json['collection'] as String, 19 + rkey: json['rkey'] as String, 20 + ); 21 + } 22 + } 23 + 24 + class ManyToManyItem { 25 + const ManyToManyItem({required this.linkRecord, required this.otherSubject}); 26 + 27 + final ConstellationLinkRecord linkRecord; 28 + final String otherSubject; 29 + 30 + factory ManyToManyItem.fromJson(Map<String, dynamic> json) { 31 + return ManyToManyItem( 32 + linkRecord: ConstellationLinkRecord.fromJson(json['linkRecord'] as Map<String, dynamic>), 33 + otherSubject: json['otherSubject'] as String, 34 + ); 35 + } 36 + } 37 + 38 + class ConstellationException implements Exception { 39 + const ConstellationException(this.message); 40 + 41 + final String message; 42 + 43 + @override 44 + String toString() => 'ConstellationException: $message'; 45 + } 46 + 47 + class ConstellationClient { 48 + ConstellationClient({String? baseUrl, http.Client? httpClient}) 49 + : _baseUrl = baseUrl ?? _defaultBaseUrl, 50 + _httpClient = httpClient ?? http.Client(); 51 + 52 + final String _baseUrl; 53 + final http.Client _httpClient; 54 + 55 + Uri _xrpcUri(String endpoint, Map<String, String?> params) { 56 + final filtered = <String, String>{}; 57 + for (final entry in params.entries) { 58 + if (entry.value != null) filtered[entry.key] = entry.value!; 59 + } 60 + final base = Uri.parse('$_baseUrl/xrpc/$endpoint'); 61 + return filtered.isEmpty ? base : base.replace(queryParameters: filtered); 62 + } 63 + 64 + Future<Map<String, dynamic>> _get(Uri uri) async { 65 + final response = await _httpClient.get(uri, headers: {'User-Agent': 'lazurite'}).timeout(_kTimeout); 66 + 67 + if (response.statusCode != 200) { 68 + throw ConstellationException('HTTP ${response.statusCode}: ${response.body}'); 69 + } 70 + 71 + return jsonDecode(response.body) as Map<String, dynamic>; 72 + } 73 + 74 + Future<int> getBacklinksCount(String subject, String source) async { 75 + final uri = _xrpcUri('blue.microcosm.links.getBacklinksCount', {'subject': subject, 'source': source}); 76 + final data = await _get(uri); 77 + return data['total'] as int; 78 + } 79 + 80 + Future<({int total, List<String> dids, String? cursor})> getDistinct( 81 + String subject, 82 + String source, { 83 + int? limit, 84 + String? cursor, 85 + }) async { 86 + final uri = _xrpcUri('blue.microcosm.links.getDistinct', { 87 + 'subject': subject, 88 + 'source': source, 89 + if (limit != null) 'limit': limit.toString(), 90 + 'cursor': cursor, 91 + }); 92 + final data = await _get(uri); 93 + return ( 94 + total: data['total'] as int, 95 + dids: (data['dids'] as List<dynamic>).cast<String>(), 96 + cursor: data['cursor'] as String?, 97 + ); 98 + } 99 + 100 + Future<({int total, List<ConstellationLinkRecord> records, String? cursor})> getBacklinks( 101 + String subject, 102 + String source, { 103 + int? limit, 104 + String? cursor, 105 + }) async { 106 + final uri = _xrpcUri('blue.microcosm.links.getBacklinks', { 107 + 'subject': subject, 108 + 'source': source, 109 + if (limit != null) 'limit': limit.toString(), 110 + 'cursor': cursor, 111 + }); 112 + final data = await _get(uri); 113 + final records = (data['linking_records'] as List<dynamic>) 114 + .map((r) => ConstellationLinkRecord.fromJson(r as Map<String, dynamic>)) 115 + .toList(); 116 + return (total: data['total'] as int, records: records, cursor: data['cursor'] as String?); 117 + } 118 + 119 + Future<({List<ManyToManyItem> items, String? cursor})> getManyToMany( 120 + String subject, 121 + String source, 122 + String pathToOther, { 123 + int? limit, 124 + String? cursor, 125 + }) async { 126 + final uri = _xrpcUri('blue.microcosm.links.getManyToMany', { 127 + 'subject': subject, 128 + 'source': source, 129 + 'pathToOther': pathToOther, 130 + if (limit != null) 'limit': limit.toString(), 131 + 'cursor': cursor, 132 + }); 133 + final data = await _get(uri); 134 + final items = (data['items'] as List<dynamic>) 135 + .map((i) => ManyToManyItem.fromJson(i as Map<String, dynamic>)) 136 + .toList(); 137 + return (items: items, cursor: data['cursor'] as String?); 138 + } 139 + }
+11
lib/features/settings/bloc/settings_cubit.dart
··· 13 13 FeedLayout? initialFeedLayout, 14 14 bool? initialSimulateOffline, 15 15 int? initialThreadAutoCollapseDepth, 16 + String? initialConstellationUrl, 16 17 }) : super( 17 18 SettingsState( 18 19 themePalette: initialPalette ?? AppThemePalette.oxocarbon, ··· 21 22 feedLayout: initialFeedLayout ?? FeedLayout.card, 22 23 simulateOffline: initialSimulateOffline ?? false, 23 24 threadAutoCollapseDepth: initialThreadAutoCollapseDepth, 25 + constellationUrl: initialConstellationUrl ?? 'https://constellation.microcosm.blue', 24 26 ), 25 27 ); 26 28 ··· 33 35 static const String _legacyKeyFeedArchitecture = 'feed_architecture'; 34 36 static const String _keySimulateOffline = 'simulate_offline'; 35 37 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 38 + static const String _keyConstellationUrl = 'constellation_url'; 39 + static const String _defaultConstellationUrl = 'https://constellation.microcosm.blue'; 36 40 37 41 Future<void> loadSettings() async { 38 42 final paletteStr = await database.getSetting(_keyThemePalette); ··· 42 46 await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); 43 47 final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 44 48 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 49 + final constellationUrlStr = await database.getSetting(_keyConstellationUrl); 45 50 46 51 emit( 47 52 state.copyWith( ··· 51 56 feedLayout: FeedLayout.fromString(feedLayoutStr), 52 57 simulateOffline: simulateOfflineStr == 'true', 53 58 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 59 + constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, 54 60 ), 55 61 ); 56 62 } ··· 94 100 await database.setSetting(_keyThreadAutoCollapseDepth, depth.toString()); 95 101 } 96 102 emit(state.copyWith(threadAutoCollapseDepth: depth)); 103 + } 104 + 105 + Future<void> setConstellationUrl(String url) async { 106 + await database.setSetting(_keyConstellationUrl, url); 107 + emit(state.copyWith(constellationUrl: url)); 97 108 } 98 109 }
+5
lib/features/settings/bloc/settings_state.dart
··· 12 12 this.feedLayout = FeedLayout.card, 13 13 this.simulateOffline = false, 14 14 this.threadAutoCollapseDepth, 15 + this.constellationUrl = 'https://constellation.microcosm.blue', 15 16 }); 16 17 17 18 final AppThemePalette themePalette; ··· 20 21 final FeedLayout feedLayout; 21 22 final bool simulateOffline; 22 23 final int? threadAutoCollapseDepth; 24 + final String constellationUrl; 23 25 24 26 SettingsState copyWith({ 25 27 AppThemePalette? themePalette, ··· 28 30 FeedLayout? feedLayout, 29 31 bool? simulateOffline, 30 32 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 33 + String? constellationUrl, 31 34 }) { 32 35 return SettingsState( 33 36 themePalette: themePalette ?? this.themePalette, ··· 38 41 threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) 39 42 ? this.threadAutoCollapseDepth 40 43 : threadAutoCollapseDepth as int?, 44 + constellationUrl: constellationUrl ?? this.constellationUrl, 41 45 ); 42 46 } 43 47 ··· 49 53 feedLayout, 50 54 simulateOffline, 51 55 threadAutoCollapseDepth, 56 + constellationUrl, 52 57 ]; 53 58 }
+64
lib/features/settings/presentation/settings_screen.dart
··· 87 87 onTap: () => context.push('/saved'), 88 88 ), 89 89 const SizedBox(height: 24), 90 + _buildSectionHeader(context, 'Advanced'), 91 + _buildAdvancedSettings(context), 92 + const SizedBox(height: 24), 90 93 if (!kReleaseMode) ...[ 91 94 _buildSectionHeader(context, 'Developer'), 92 95 _buildDeveloperSettings(context), ··· 252 255 onChanged: settingsCubit.setThreadAutoCollapseDepth, 253 256 ), 254 257 ], 258 + ), 259 + ); 260 + }, 261 + ); 262 + } 263 + 264 + Widget _buildAdvancedSettings(BuildContext context) { 265 + return BlocBuilder<SettingsCubit, SettingsState>( 266 + builder: (context, state) { 267 + return Container( 268 + decoration: BoxDecoration( 269 + border: Border( 270 + top: BorderSide(color: Theme.of(context).dividerColor), 271 + bottom: BorderSide(color: Theme.of(context).dividerColor), 272 + ), 273 + color: Theme.of(context).cardColor, 274 + ), 275 + child: _ConstellationUrlTile( 276 + currentUrl: state.constellationUrl, 277 + onChanged: (url) => context.read<SettingsCubit>().setConstellationUrl(url), 255 278 ), 256 279 ); 257 280 }, ··· 509 532 Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), 510 533 ], 511 534 ), 535 + ); 536 + } 537 + } 538 + 539 + class _ConstellationUrlTile extends StatelessWidget { 540 + const _ConstellationUrlTile({required this.currentUrl, required this.onChanged}); 541 + 542 + final String currentUrl; 543 + final ValueChanged<String> onChanged; 544 + 545 + Future<void> _showEditDialog(BuildContext context) async { 546 + final controller = TextEditingController(text: currentUrl); 547 + final result = await showDialog<String>( 548 + context: context, 549 + builder: (context) => AlertDialog( 550 + title: const Text('Constellation URL'), 551 + content: TextField( 552 + controller: controller, 553 + keyboardType: TextInputType.url, 554 + autocorrect: false, 555 + decoration: const InputDecoration(hintText: 'https://constellation.microcosm.blue'), 556 + ), 557 + actions: [ 558 + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), 559 + TextButton(onPressed: () => Navigator.of(context).pop(controller.text.trim()), child: const Text('Save')), 560 + ], 561 + ), 562 + ); 563 + if (result != null && result.isNotEmpty) { 564 + onChanged(result); 565 + } 566 + } 567 + 568 + @override 569 + Widget build(BuildContext context) { 570 + return ListTile( 571 + leading: const Icon(Icons.hub_outlined), 572 + title: const Text('Constellation URL'), 573 + subtitle: Text(currentUrl, maxLines: 1, overflow: TextOverflow.ellipsis), 574 + trailing: const Icon(Icons.edit_outlined), 575 + onTap: () => _showEditDialog(context), 512 576 ); 513 577 } 514 578 }
+319
test/core/network/constellation_client_test.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:http/http.dart' as http; 6 + import 'package:http/testing.dart'; 7 + import 'package:lazurite/core/network/constellation_client.dart'; 8 + 9 + void main() { 10 + group('ConstellationClient', () { 11 + group('URL construction', () { 12 + test('uses default base URL when none provided', () async { 13 + Uri? capturedUri; 14 + final client = ConstellationClient( 15 + httpClient: MockClient((request) async { 16 + capturedUri = request.url; 17 + return http.Response(jsonEncode({'total': 0}), 200); 18 + }), 19 + ); 20 + 21 + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); 22 + 23 + expect(capturedUri?.host, 'constellation.microcosm.blue'); 24 + expect(capturedUri?.scheme, 'https'); 25 + }); 26 + 27 + test('uses custom base URL when provided', () async { 28 + Uri? capturedUri; 29 + final client = ConstellationClient( 30 + baseUrl: 'https://my.instance.example', 31 + httpClient: MockClient((request) async { 32 + capturedUri = request.url; 33 + return http.Response(jsonEncode({'total': 0}), 200); 34 + }), 35 + ); 36 + 37 + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); 38 + 39 + expect(capturedUri?.host, 'my.instance.example'); 40 + }); 41 + 42 + test('builds XRPC path correctly', () async { 43 + Uri? capturedUri; 44 + final client = ConstellationClient( 45 + httpClient: MockClient((request) async { 46 + capturedUri = request.url; 47 + return http.Response(jsonEncode({'total': 0}), 200); 48 + }), 49 + ); 50 + 51 + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); 52 + 53 + expect(capturedUri?.path, '/xrpc/blue.microcosm.links.getBacklinksCount'); 54 + }); 55 + 56 + test('sends User-Agent: lazurite header', () async { 57 + Map<String, String>? capturedHeaders; 58 + final client = ConstellationClient( 59 + httpClient: MockClient((request) async { 60 + capturedHeaders = request.headers; 61 + return http.Response(jsonEncode({'total': 0}), 200); 62 + }), 63 + ); 64 + 65 + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); 66 + 67 + expect(capturedHeaders?['User-Agent'], 'lazurite'); 68 + }); 69 + }); 70 + 71 + group('getBacklinksCount', () { 72 + test('returns total from response', () async { 73 + final client = ConstellationClient( 74 + httpClient: MockClient((_) async => http.Response(jsonEncode({'total': 42}), 200)), 75 + ); 76 + 77 + final result = await client.getBacklinksCount('did:plc:abc', 'app.bsky.graph.block:subject'); 78 + 79 + expect(result, 42); 80 + }); 81 + 82 + test('encodes subject and source as query parameters', () async { 83 + Uri? capturedUri; 84 + final client = ConstellationClient( 85 + httpClient: MockClient((request) async { 86 + capturedUri = request.url; 87 + return http.Response(jsonEncode({'total': 0}), 200); 88 + }), 89 + ); 90 + 91 + await client.getBacklinksCount('did:plc:abc123', 'app.bsky.graph.block:subject'); 92 + 93 + expect(capturedUri?.queryParameters['subject'], 'did:plc:abc123'); 94 + expect(capturedUri?.queryParameters['source'], 'app.bsky.graph.block:subject'); 95 + }); 96 + 97 + test('throws ConstellationException on non-200 response', () async { 98 + final client = ConstellationClient( 99 + httpClient: MockClient((_) async => http.Response('Internal Server Error', 500)), 100 + ); 101 + 102 + expect(() => client.getBacklinksCount('did:plc:abc', 'source'), throwsA(isA<ConstellationException>())); 103 + }); 104 + 105 + test('throws on timeout', () async { 106 + final client = ConstellationClient( 107 + httpClient: MockClient((_) async { 108 + await Future<void>.delayed(const Duration(seconds: 15)); 109 + return http.Response('', 200); 110 + }), 111 + ); 112 + 113 + expect(() => client.getBacklinksCount('did:plc:abc', 'source'), throwsA(isA<TimeoutException>())); 114 + }, timeout: const Timeout(Duration(seconds: 20))); 115 + }); 116 + 117 + group('getDistinct', () { 118 + test('returns total, dids and cursor from response', () async { 119 + final responseBody = jsonEncode({ 120 + 'total': 3, 121 + 'dids': ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], 122 + 'cursor': 'next-cursor', 123 + }); 124 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 125 + 126 + final result = await client.getDistinct('did:plc:abc', 'app.bsky.graph.block:subject'); 127 + 128 + expect(result.total, 3); 129 + expect(result.dids, ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc']); 130 + expect(result.cursor, 'next-cursor'); 131 + }); 132 + 133 + test('returns null cursor when absent', () async { 134 + final responseBody = jsonEncode({ 135 + 'total': 1, 136 + 'dids': ['did:plc:aaa'], 137 + }); 138 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 139 + 140 + final result = await client.getDistinct('did:plc:abc', 'source'); 141 + 142 + expect(result.cursor, isNull); 143 + }); 144 + 145 + test('passes limit and cursor as query parameters', () async { 146 + Uri? capturedUri; 147 + final client = ConstellationClient( 148 + httpClient: MockClient((request) async { 149 + capturedUri = request.url; 150 + return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); 151 + }), 152 + ); 153 + 154 + await client.getDistinct('did:plc:abc', 'source', limit: 25, cursor: 'some-cursor'); 155 + 156 + expect(capturedUri?.queryParameters['limit'], '25'); 157 + expect(capturedUri?.queryParameters['cursor'], 'some-cursor'); 158 + }); 159 + 160 + test('omits limit and cursor when not provided', () async { 161 + Uri? capturedUri; 162 + final client = ConstellationClient( 163 + httpClient: MockClient((request) async { 164 + capturedUri = request.url; 165 + return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); 166 + }), 167 + ); 168 + 169 + await client.getDistinct('did:plc:abc', 'source'); 170 + 171 + expect(capturedUri?.queryParameters.containsKey('limit'), isFalse); 172 + expect(capturedUri?.queryParameters.containsKey('cursor'), isFalse); 173 + }); 174 + 175 + test('throws ConstellationException on non-200 response', () async { 176 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Not Found', 404))); 177 + 178 + expect(() => client.getDistinct('did:plc:abc', 'source'), throwsA(isA<ConstellationException>())); 179 + }); 180 + }); 181 + 182 + group('getBacklinks', () { 183 + test('returns total, records and cursor from response', () async { 184 + final responseBody = jsonEncode({ 185 + 'total': 2, 186 + 'linking_records': [ 187 + {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rkey1'}, 188 + {'did': 'did:plc:bbb', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rkey2'}, 189 + ], 190 + 'cursor': 'cursor-xyz', 191 + }); 192 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 193 + 194 + final result = await client.getBacklinks('did:plc:abc', 'app.bsky.graph.listitem:subject'); 195 + 196 + expect(result.total, 2); 197 + expect(result.records.length, 2); 198 + expect(result.records.first.did, 'did:plc:aaa'); 199 + expect(result.records.first.collection, 'app.bsky.graph.listitem'); 200 + expect(result.records.first.rkey, 'rkey1'); 201 + expect(result.cursor, 'cursor-xyz'); 202 + }); 203 + 204 + test('returns empty records list when none returned', () async { 205 + final responseBody = jsonEncode({'total': 0, 'linking_records': []}); 206 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 207 + 208 + final result = await client.getBacklinks('did:plc:abc', 'source'); 209 + 210 + expect(result.records, isEmpty); 211 + expect(result.cursor, isNull); 212 + }); 213 + 214 + test('passes limit and cursor as query parameters', () async { 215 + Uri? capturedUri; 216 + final client = ConstellationClient( 217 + httpClient: MockClient((request) async { 218 + capturedUri = request.url; 219 + return http.Response(jsonEncode({'total': 0, 'linking_records': []}), 200); 220 + }), 221 + ); 222 + 223 + await client.getBacklinks('did:plc:abc', 'source', limit: 50, cursor: 'page2'); 224 + 225 + expect(capturedUri?.queryParameters['limit'], '50'); 226 + expect(capturedUri?.queryParameters['cursor'], 'page2'); 227 + }); 228 + 229 + test('throws ConstellationException on error response', () async { 230 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Unauthorized', 401))); 231 + 232 + expect(() => client.getBacklinks('did:plc:abc', 'source'), throwsA(isA<ConstellationException>())); 233 + }); 234 + }); 235 + 236 + group('getManyToMany', () { 237 + test('returns items and cursor from response', () async { 238 + final responseBody = jsonEncode({ 239 + 'items': [ 240 + { 241 + 'linkRecord': {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk1'}, 242 + 'otherSubject': 'at://did:plc:aaa/app.bsky.graph.list/rk2', 243 + }, 244 + ], 245 + 'cursor': 'next', 246 + }); 247 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 248 + 249 + final result = await client.getManyToMany('did:plc:abc', 'app.bsky.graph.listitem:subject', 'list'); 250 + 251 + expect(result.items.length, 1); 252 + expect(result.items.first.linkRecord.did, 'did:plc:aaa'); 253 + expect(result.items.first.otherSubject, 'at://did:plc:aaa/app.bsky.graph.list/rk2'); 254 + expect(result.cursor, 'next'); 255 + }); 256 + 257 + test('passes pathToOther as query parameter', () async { 258 + Uri? capturedUri; 259 + final client = ConstellationClient( 260 + httpClient: MockClient((request) async { 261 + capturedUri = request.url; 262 + return http.Response(jsonEncode({'items': [], 'cursor': null}), 200); 263 + }), 264 + ); 265 + 266 + await client.getManyToMany('did:plc:abc', 'source', 'list'); 267 + 268 + expect(capturedUri?.queryParameters['pathToOther'], 'list'); 269 + expect(capturedUri?.path, '/xrpc/blue.microcosm.links.getManyToMany'); 270 + }); 271 + 272 + test('returns null cursor when absent', () async { 273 + final responseBody = jsonEncode({'items': []}); 274 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); 275 + 276 + final result = await client.getManyToMany('did:plc:abc', 'source', 'list'); 277 + 278 + expect(result.cursor, isNull); 279 + }); 280 + 281 + test('throws ConstellationException on error response', () async { 282 + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Bad Request', 400))); 283 + 284 + expect(() => client.getManyToMany('did:plc:abc', 'source', 'list'), throwsA(isA<ConstellationException>())); 285 + }); 286 + }); 287 + 288 + group('ConstellationException', () { 289 + test('toString includes message', () { 290 + const e = ConstellationException('HTTP 500: server error'); 291 + expect(e.toString(), contains('HTTP 500: server error')); 292 + }); 293 + }); 294 + 295 + group('ConstellationLinkRecord.fromJson', () { 296 + test('parses all fields', () { 297 + final record = ConstellationLinkRecord.fromJson({ 298 + 'did': 'did:plc:xyz', 299 + 'collection': 'app.bsky.graph.listitem', 300 + 'rkey': 'abc123', 301 + }); 302 + expect(record.did, 'did:plc:xyz'); 303 + expect(record.collection, 'app.bsky.graph.listitem'); 304 + expect(record.rkey, 'abc123'); 305 + }); 306 + }); 307 + 308 + group('ManyToManyItem.fromJson', () { 309 + test('parses linkRecord and otherSubject', () { 310 + final item = ManyToManyItem.fromJson({ 311 + 'linkRecord': {'did': 'did:plc:a', 'collection': 'col', 'rkey': 'rk'}, 312 + 'otherSubject': 'at://did:plc:a/app.bsky.graph.list/rk2', 313 + }); 314 + expect(item.linkRecord.did, 'did:plc:a'); 315 + expect(item.otherSubject, 'at://did:plc:a/app.bsky.graph.list/rk2'); 316 + }); 317 + }); 318 + }); 319 + }
+48
test/features/settings/bloc/settings_cubit_test.dart
··· 219 219 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), 220 220 ], 221 221 ); 222 + 223 + test('initial state has default constellation URL', () { 224 + final cubit = SettingsCubit(database: database); 225 + expect(cubit.state.constellationUrl, 'https://constellation.microcosm.blue'); 226 + }); 227 + 228 + test('accepts initial constellation URL via constructor', () { 229 + final cubit = SettingsCubit(database: database, initialConstellationUrl: 'https://my.instance.example'); 230 + expect(cubit.state.constellationUrl, 'https://my.instance.example'); 231 + }); 232 + 233 + blocTest<SettingsCubit, SettingsState>( 234 + 'setConstellationUrl updates state and persists to database', 235 + build: () => SettingsCubit(database: database), 236 + act: (cubit) => cubit.setConstellationUrl('https://custom.example.com'), 237 + expect: () => [ 238 + isA<SettingsState>().having((s) => s.constellationUrl, 'constellationUrl', 'https://custom.example.com'), 239 + ], 240 + verify: (cubit) async { 241 + final value = await database.getSetting('constellation_url'); 242 + expect(value, 'https://custom.example.com'); 243 + }, 244 + ); 245 + 246 + blocTest<SettingsCubit, SettingsState>( 247 + 'loadSettings loads persisted constellation URL', 248 + build: () => SettingsCubit(database: database), 249 + setUp: () async { 250 + await database.setSetting('constellation_url', 'https://self-hosted.example'); 251 + }, 252 + act: (cubit) => cubit.loadSettings(), 253 + expect: () => [ 254 + isA<SettingsState>().having((s) => s.constellationUrl, 'constellationUrl', 'https://self-hosted.example'), 255 + ], 256 + ); 257 + 258 + blocTest<SettingsCubit, SettingsState>( 259 + 'loadSettings uses default constellation URL when not persisted', 260 + build: () => SettingsCubit(database: database), 261 + act: (cubit) => cubit.loadSettings(), 262 + expect: () => [ 263 + isA<SettingsState>().having( 264 + (s) => s.constellationUrl, 265 + 'constellationUrl', 266 + 'https://constellation.microcosm.blue', 267 + ), 268 + ], 269 + ); 222 270 }); 223 271 }
+27
test/features/settings/presentation/settings_screen_test.dart
··· 149 149 expect(find.text('Email Notifications'), findsNothing); 150 150 expect(find.text('Help & Support'), findsNothing); 151 151 }); 152 + 153 + testWidgets('shows Advanced section with Constellation URL tile', (tester) async { 154 + await tester.pumpWidget(buildSubject()); 155 + await tester.pumpAndSettle(); 156 + 157 + await tester.scrollUntilVisible(find.text('ADVANCED'), 300); 158 + await tester.pumpAndSettle(); 159 + 160 + expect(find.text('ADVANCED'), findsOneWidget); 161 + expect(find.text('Constellation URL'), findsOneWidget); 162 + expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); 163 + }); 164 + 165 + testWidgets('Constellation URL tile opens edit dialog on tap', (tester) async { 166 + await tester.pumpWidget(buildSubject()); 167 + await tester.pumpAndSettle(); 168 + 169 + await tester.scrollUntilVisible(find.text('Constellation URL'), 300); 170 + await tester.pumpAndSettle(); 171 + 172 + await tester.tap(find.text('Constellation URL')); 173 + await tester.pumpAndSettle(); 174 + 175 + expect(find.text('Constellation URL'), findsWidgets); 176 + expect(find.text('Cancel'), findsOneWidget); 177 + expect(find.text('Save'), findsOneWidget); 178 + }); 152 179 } 153 180 154 181 String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) {