From 02ffda773adb22313bf273667825ebf0a3dd41b9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 31 Mar 2026 23:55:22 -0500 Subject: [PATCH] feat: constellation client with settings --- docs/specs/phase-5.md | 138 +++++++- docs/specs/phase-6.md | 123 +++++++ docs/tasks/phase-5.md | 83 ++++- docs/tasks/phase-6.md | 81 +++++ lib/core/network/constellation_client.dart | 139 ++++++++ .../settings/bloc/settings_cubit.dart | 11 + .../settings/bloc/settings_state.dart | 5 + .../presentation/settings_screen.dart | 64 ++++ .../network/constellation_client_test.dart | 319 ++++++++++++++++++ .../settings/bloc/settings_cubit_test.dart | 48 +++ .../presentation/settings_screen_test.dart | 27 ++ 11 files changed, 1016 insertions(+), 22 deletions(-) create mode 100644 docs/specs/phase-6.md create mode 100644 docs/tasks/phase-6.md create mode 100644 lib/core/network/constellation_client.dart create mode 100644 test/core/network/constellation_client_test.dart diff --git a/docs/specs/phase-5.md b/docs/specs/phase-5.md index 0885a5d..540276b 100644 --- a/docs/specs/phase-5.md +++ b/docs/specs/phase-5.md @@ -1,11 +1,11 @@ --- title: Phase 5 Spec -updated: 2026-03-25 +updated: 2026-03-31 --- ## Feature Parity -Three new endpoint integrations to round out UI coverage. +Three new endpoint integrations to round out UI coverage, plus two Constellation-powered features. --- @@ -18,9 +18,9 @@ Three new endpoint integrations to round out UI coverage. | Param | Type | Required | Default | Notes | |----------|--------|----------|---------|-------------------------------| -| `q` | string | yes | — | Lucene-style query | +| `q` | string | yes | - | Lucene-style query | | `limit` | int | no | 25 | 1–100 | -| `cursor` | string | no | — | Pagination cursor | +| `cursor` | string | no | - | Pagination cursor | **Response:** @@ -65,7 +65,7 @@ the profile starter packs tab. } ``` -No pagination — returns all suggestions in one response. +No pagination - returns all suggestions in one response. **SDK:** `bluesky.graph.getSuggestedFollowsByActor(actor:)` → `XRPCResponse` @@ -104,3 +104,131 @@ quota. Display remaining video count and remaining bytes (formatted as MB/GB). Show `canUpload` status and any server `message`. Fetch on screen load; show loading indicator while fetching. If the endpoint returns an error or `canUpload` is false, show the reason. + +--- + +### 4. Profile Context (Constellation) + +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. + +**Design philosophy** (carried from lazurite-desktop diagnostics): + +- Inform, don't alarm. Present data neutrally. +- No composite risk scores. Show the data; let the user interpret it. +- Context over counts. Prefer showing _what kind_ of lists over _how many_. +- Respect the viewed account. Default to aggregate summaries; expand to specifics on request. + +#### Constellation Client + +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. + +All endpoints use XRPC format at `{base}/xrpc/{endpoint}` with query parameters. + +#### 4a. Blocked By (incoming blocks) + +**Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinksCount` +**Purpose:** Count of accounts that have blocked this user. + +| Param | Type | Required | Notes | +|----------|--------|----------|------------------------------------| +| `subject`| string | yes | Target DID | +| `source` | string | yes | `app.bsky.graph.block:subject` | + +**Response:** `{ "total": int }` + +**Detail list endpoint:** `GET /xrpc/blue.microcosm.links.getDistinct` + +| Param | Type | Required | Default | Notes | +|----------|--------|----------|---------|------------------------------------| +| `subject`| string | yes | - | Target DID | +| `source` | string | yes | - | `app.bsky.graph.block:subject` | +| `limit` | int | no | 16 | Max 100 | +| `cursor` | string | no | - | Pagination cursor | + +**Response:** `{ "total": int, "dids": string[], "cursor": string? }` + +Returned DIDs are hydrated via `bluesky.actor.getProfiles(actors:)` (batch, max 25 per call) to show profile cards. + +#### 4b. Users Blocked (outgoing blocks) + +**Endpoint:** `GET /xrpc/com.atproto.repo.listRecords` (AT Protocol, not Constellation) + +| Param | Type | Required | Default | Notes | +|-------------|--------|----------|---------|-------------------------------| +| `repo` | string | yes | - | Actor DID | +| `collection`| string | yes | - | `app.bsky.graph.block` | +| `limit` | int | no | 50 | Max 100 | +| `cursor` | string | no | - | Pagination cursor | + +**Response:** `{ "records": Record[], "cursor": string? }` + +Each record's `value.subject` is the blocked DID. Hydrate via `getProfiles`. + +**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. + +#### 4c. Lists On + +**Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinks` + +| Param | Type | Required | Default | Notes | +|----------|--------|----------|---------|-----------------------------------------| +| `subject`| string | yes | - | Target DID | +| `source` | string | yes | - | `app.bsky.graph.listitem:subject` | +| `limit` | int | no | 16 | Max 100 | +| `cursor` | string | no | - | Pagination cursor | + +**Response:** + +```json +{ + "total": "int", + "linking_records": [{ "did": "string", "collection": "string", "rkey": "string" }], + "cursor": "string?" +} +``` + +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: + +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). +2. **Fallback**: fetch each listitem record via `com.atproto.repo.getRecord` to read its `list` field, then hydrate lists via `bluesky.graph.getList`. + +Hydrate list metadata via `bluesky.graph.getList(list:)` to show name, purpose, owner, member count. + +#### Profile Context UI + +**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. + +**Route:** `/profile-context?did={DID}` + +**Screen layout:** + +- `AppBar` with title "Profile Context" and the user's handle as subtitle +- Three-tab `TabBar`: **Blocked By** | **Blocking** | **Lists** +- Each tab is a paginated list with pull-to-refresh + +**Blocked By tab:** + +- Header row: total count (from `getBacklinksCount`) displayed prominently +- "Show accounts" expand button - on tap, fetches DIDs via `getDistinct` and hydrates profiles +- Profile tiles: avatar, display name, handle. Tap → navigate to profile +- Pagination via cursor (infinite scroll) +- Contextualizing note: _"Blocks are a normal part of social media. This data is public on the AT Protocol."_ + +**Blocking tab:** + +- Same layout as Blocked By but sourced from `listRecords` +- Only available when viewing own profile or if the repo is publicly readable +- When unavailable: show explanatory text + +**Lists tab:** + +- List cards: name, owner handle, purpose badge (curate/modlist/reference), member count, description snippet +- Grouped by purpose (curation first, then moderation, then reference) +- Tap → navigate to list detail screen (`/list?uri=`) +- Pagination via cursor + +**States:** + +- Loading: skeleton shimmer matching card dimensions +- Empty: per-tab contextual empty state (e.g., "Not on any lists" / "No blocks found") +- Error: inline retry button per tab, not full-screen error diff --git a/docs/specs/phase-6.md b/docs/specs/phase-6.md new file mode 100644 index 0000000..d8e077a --- /dev/null +++ b/docs/specs/phase-6.md @@ -0,0 +1,123 @@ +--- +title: Phase 6 Spec +updated: 2026-03-31 +--- + +## Social Graph Visualization + +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. + +### Data Sources + +The graph is built from three relationship sets for a target DID: + +**Follows (outgoing):** `bluesky.graph.getFollows(actor:, limit:, cursor:)` +→ Paginated `ProfileView[]`. Fetch up to a configurable cap (default: 200) to keep rendering performant. + +**Followers (incoming):** `bluesky.graph.getFollowers(actor:, limit:, cursor:)` +→ Paginated `ProfileView[]`. Same cap. + +**Mutual detection:** Intersect the two sets by DID. Mutuals get a distinct edge style (thicker, accent-colored). + +**Constellation backlinks for extended context:** + +- `getDistinct` with `source=app.bsky.graph.block:subject` to identify blocked/blocking edges (rendered as dashed/muted) +- `getBacklinksCount` for aggregate stats shown in the info card + +### Graph Model + +```dart +GraphNode { + did: String + handle: String + displayName: String? + avatarUrl: String? + bannerUrl: String? // for the detail card + pdsHost: String // extracted from DID document or handle resolution + relationship: enum { mutual, following, follower } +} + +GraphEdge { + source: DID + target: DID + type: enum { mutual, follows, followedBy } + weight: double // mutual = 1.0, one-way = 0.5 +} +``` + +### Force-Directed Layout + +Use a velocity Verlet integration loop on a `CustomPainter` canvas (no external package dependency): + +- **Center node:** the target user, pinned at canvas center +- **Charge repulsion:** all nodes repel (Barnes-Hut approximation for O(n log n)) +- **Spring attraction:** connected nodes attract along edges; mutuals have stronger spring constant +- **Edge rendering:** straight lines, color-coded by type (mutual = accent, one-way = muted) +- **Node rendering:** circular avatar with border ring colored by relationship type +- **Tick loop:** driven by `Ticker` (vsync), capped at 60fps, with cooling - simulation freezes after convergence + +**Interaction:** + +- Pan & pinch-zoom via `InteractiveViewer` wrapping the canvas +- Tap node → show user info card (overlay, not navigation) +- Drag node → pin it in place; release to unpin + +**Performance constraints:** + +- Cap total nodes at ~250 to stay smooth on mid-range devices +- Avatars loaded lazily as `ImageProvider` textures, with placeholder circle until loaded +- Simulation auto-pauses when graph settles (kinetic energy below threshold) + +### User Info Card (overlay) + +Shown as a `Material` card overlaying the graph canvas when a node is tapped. Positioned near the tapped node (clamped to viewport). + +**Layout:** + +- Banner image (cover photo) as card header (120px, fallback: gradient) +- Avatar circle overlapping banner bottom-left (56px) +- Display name (bold) + handle (secondary) +- DID (monospace, truncated with copy button) +- PDS host (e.g., `bsky.network`, derived from DID doc `#atproto_pds` service endpoint) +- "View Profile" button → navigates to `/profile?did={DID}` + +**Dismiss:** tap outside card, swipe down, or tap X button. + +### PDS Resolution + +To show the PDS host in the info card, resolve the DID document: + +**For `did:plc`:** `GET https://plc.directory/{did}` +→ Response includes `service` array; find entry with `id: "#atproto_pds"`, extract `serviceEndpoint` host. + +**For `did:web`:** `GET https://{handle}/.well-known/did.json` +→ Same structure. + +Cache resolved PDS hosts in memory (Map) for the session - DID docs rarely change. + +### Screen & Navigation + +**Route:** `/social-graph?did={DID}` + +**Entry point:** New "Social Graph" item in the profile screen's overflow menu, below "Profile Context". Available for all profiles (own and others). + +**Screen layout:** + +- `AppBar` with title "Social Graph" and handle subtitle +- Full-bleed canvas below +- Floating legend chip row at bottom: colored dots with labels (Mutual / Following / Follower) +- Loading state: centered spinner with "Building graph..." text while fetching follows/followers +- Error state: retry button + +**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. + +### Package Considerations + +No external graph package. Implement with: + +- `CustomPainter` for rendering +- `GestureDetector` / `InteractiveViewer` for interaction +- `Ticker` for animation loop +- `dart:ui` `Canvas` for drawing circles, lines, images + +This avoids dependency risk and gives full control over the mobile-optimized rendering pipeline. diff --git a/docs/tasks/phase-5.md b/docs/tasks/phase-5.md index ba1a123..e4fe1f2 100644 --- a/docs/tasks/phase-5.md +++ b/docs/tasks/phase-5.md @@ -1,26 +1,26 @@ --- title: Phase 5 Task Breakdown -updated: 2026-03-25 +updated: 2026-03-31 --- # Phase 5 Milestones -## M20 — Starter Pack Search +## M20 - Starter Pack Search ### Core -- [ ] `SearchRepository.searchStarterPacks()` — call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List` and cursor +- [ ] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List` and cursor - [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension ### Cubit -- [ ] `SearchBloc` — handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination -- [ ] `SearchState` — add `starterPacks` list and `starterPacksCursor` fields +- [ ] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination +- [ ] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields ### UI -- [ ] Search screen UI — add third "Starter Packs" tab pill in `_buildTab` row -- [ ] Starter pack result tile widget — show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab +- [ ] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row +- [ ] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab - [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) - [ ] Infinite scroll pagination for starter packs tab @@ -29,20 +29,20 @@ updated: 2026-03-25 - [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination - [ ] Widget tests: third tab renders, results display, empty state, tap navigation -## M21 — Suggested Follows Sheet +## M21 - Suggested Follows Sheet ### Core -- [ ] `ProfileRepository.getSuggestedFollows()` — call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List` +- [ ] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List` ### Cubit -- [ ] `SuggestedFollowsCubit` — `load(actor:)` fetches suggestions, exposes loaded/loading/error states +- [ ] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states ### UI -- [ ] Suggested follows sheet widget — `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons -- [ ] Profile screen overflow menu — add "Suggested Follows" `ListTile` entry; hide when viewing own profile +- [ ] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons +- [ ] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile - [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` - [ ] Tap profile tile → pop sheet, navigate to profile screen - [ ] Empty state when no suggestions returned @@ -52,20 +52,20 @@ updated: 2026-03-25 - [ ] Unit tests: repository method, cubit state transitions - [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state -## M22 — Video Upload Limits +## M22 - Video Upload Limits ### Core -- [ ] `VideoRepository` (or extend settings repository) — `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result +- [ ] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result ### Cubit -- [ ] `VideoUploadLimitsCubit` — fetch on init, expose `canUpload`, remaining counts, message/error +- [ ] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error ### UI -- [ ] Settings screen — new tile in Account section: "Video Upload Limits" -- [ ] Tile UI — show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge +- [ ] Settings screen - new tile in Account section: "Video Upload Limits" +- [ ] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge - [ ] Loading state while fetching, error state if request fails - [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false @@ -73,3 +73,52 @@ updated: 2026-03-25 - [ ] Unit tests: repository method, cubit state transitions and formatting - [ ] Widget tests: tile renders limits, loading indicator, error state, message display + +## M23 - Profile Context (Constellation) + +### Core - Constellation Client + +- [x] `ConstellationClient` - thin HTTP client (`http` package) targeting configurable base URL (default `https://constellation.microcosm.blue`), 10s timeout, `User-Agent: lazurite` +- [x] `Settings` - add `constellation_url` key with default value; expose in Settings screen under "Advanced" +- [x] `getBacklinksCount(subject, source)` → `int` total +- [x] `getDistinct(subject, source, {limit, cursor})` → `({int total, List dids, String? cursor})` +- [x] `getBacklinks(subject, source, {limit, cursor})` → `({int total, List records, String? cursor})` +- [x] `getManyToMany(subject, source, pathToOther, {limit, cursor})` → `({List items, String? cursor})` +- [x] `ConstellationLinkRecord` model - `did`, `collection`, `rkey` +- [x] `ManyToManyItem` model - `linkRecord: ConstellationLinkRecord`, `otherSubject: String` + +### Core - Profile Context Repository + +- [ ] `ProfileContextRepository` - depends on `ConstellationClient` + `Bluesky` +- [ ] `getBlockedByCount(did)` - calls `getBacklinksCount(did, 'app.bsky.graph.block:subject')` +- [ ] `getBlockedByProfiles(did, {cursor})` - calls `getDistinct`, hydrates DIDs via `bluesky.actor.getProfiles` (batched 25), returns `({List profiles, String? cursor, int total})` +- [ ] `getBlockingProfiles(did, {cursor})` - calls `com.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block')`, extracts subject DIDs, hydrates via `getProfiles`, returns same shape +- [ ] `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 lists, String? cursor, int total})` + +### Cubit + +- [ ] `ProfileContextCubit` - manages tab state, loads counts on init for all three tabs +- [ ] `ProfileContextState` - fields: `blockedByCount`, `blockingCount`, `listsOnCount`, per-tab `status` (initial/loading/loaded/error), per-tab item list + cursor +- [ ] `loadBlockedBy({cursor})` - fetches page of blocked-by profiles, appends to state +- [ ] `loadBlocking({cursor})` - fetches page of blocking profiles, appends to state +- [ ] `loadListsOn({cursor})` - fetches page of lists, appends to state +- [ ] Handle own-profile vs other-profile: blocking tab only available for own profile + +### UI + +- [ ] Profile screen overflow menu - add "Profile Context" entry (available for all profiles) +- [ ] Route: `/profile-context?did={DID}` in `app_router.dart` +- [ ] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit +- [ ] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text +- [ ] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles +- [ ] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=` +- [ ] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry +- [ ] Pull-to-refresh per tab +- [ ] Infinite scroll pagination per tab + +### Tests + +- [ ] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction +- [ ] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough +- [ ] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending +- [ ] 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 diff --git a/docs/tasks/phase-6.md b/docs/tasks/phase-6.md new file mode 100644 index 0000000..7a9e40d --- /dev/null +++ b/docs/tasks/phase-6.md @@ -0,0 +1,81 @@ +--- +title: Phase 6 Task Breakdown +updated: 2026-03-31 +--- + +# Phase 6 Milestones + +## M25 - Social Graph Visualization + +### Core - Graph Data + +- [ ] `SocialGraphRepository` - depends on `Bluesky` & `ConstellationClient` +- [ ] `getFollows(did, {limit, cursor})` - wraps `bluesky.graph.getFollows`, returns `({List profiles, String? cursor})` +- [ ] `getFollowers(did, {limit, cursor})` - wraps `bluesky.graph.getFollowers`, returns same shape +- [ ] `fetchGraphData(did, {maxNodes: 200})` - fetches follows + followers up to cap, computes mutual set by DID intersection, returns `GraphData` +- [ ] `GraphNode` model - `did`, `handle`, `displayName`, `avatarUrl`, `bannerUrl`, `relationship` (enum: mutual/following/follower) +- [ ] `GraphEdge` model - `sourceDid`, `targetDid`, `type` (enum: mutual/follows/followedBy), `weight` +- [ ] `GraphData` model - `centerDid`, `List nodes`, `List edges` + +### Core - PDS Resolution + +- [ ] `PdsResolver` - resolves DID → PDS host +- [ ] `did:plc` resolution - `GET https://plc.directory/{did}`, parse `service` array for `#atproto_pds` entry, extract host from `serviceEndpoint` +- [ ] `did:web` resolution - `GET https://{identifier}/.well-known/did.json`, same parsing +- [ ] In-memory cache (`Map`) for resolved PDS hosts, per session + +### Core - Force-Directed Layout Engine + +- [ ] `ForceSimulation` class - velocity Verlet integration, configurable parameters +- [ ] Charge repulsion force - Barnes-Hut quadtree approximation for O(n log n) +- [ ] Spring attraction force - edge-based, stronger constant for mutual edges +- [ ] Center gravity - gentle pull toward canvas center to prevent drift +- [ ] Cooling schedule - alpha decay, auto-pause when kinetic energy < threshold +- [ ] `SimulationNode` - position (`Offset`), velocity, pinned flag, reference to `GraphNode` +- [ ] `tick()` method - single simulation step, returns whether simulation is still active + +### Cubit + +- [ ] `SocialGraphCubit` - `load(did)` fetches graph data, initializes simulation +- [ ] `SocialGraphState` - fields: `status` (loading/loaded/error), `graphData`, `selectedNode`, `simulationActive` +- [ ] `selectNode(did)` - sets selected node for info card display +- [ ] `dismissCard()` - clears selected node +- [ ] `pinNode(did, offset)` / `unpinNode(did)` - for drag interaction + +### UI - Graph Canvas + +- [ ] `SocialGraphScreen` - route `/social-graph?did={DID}`, `AppBar` + full-bleed canvas +- [ ] `GraphPainter extends CustomPainter` - renders edges (lines, color-coded), nodes (avatar circles with relationship-colored borders) +- [ ] `InteractiveViewer` wrapping `CustomPaint` for pan + pinch-zoom +- [ ] `Ticker`-driven animation loop - calls `ForceSimulation.tick()`, triggers repaint via `notifyListeners` +- [ ] Lazy avatar loading - `ImageProvider` textures, placeholder colored circle until loaded +- [ ] Tap detection - hit-test nodes by distance from touch point, emit `selectNode` +- [ ] Drag detection - `GestureDetector` on nodes for pin/unpin +- [ ] Floating legend row - colored dots: Mutual / Following / Follower +- [ ] Loading state - centered spinner + "Building graph..." +- [ ] Error state - retry button +- [ ] Progressive loading - start rendering when first follows page arrives, add nodes as more pages load + +### UI - Info Card Overlay + +- [ ] `GraphInfoCard` widget - `Material` card positioned near tapped node (clamped to viewport bounds) +- [ ] Banner image header (120px, gradient fallback) +- [ ] Avatar circle (56px) overlapping banner +- [ ] Display name + handle +- [ ] DID row - monospace, truncated, copy-to-clipboard button +- [ ] PDS host - resolved via `PdsResolver`, loading indicator until resolved +- [ ] "View Profile" `FilledButton` → pop card, navigate to `/profile?did={DID}` +- [ ] Dismiss: tap outside, swipe down, or X button + +### UI - Entry Point + +- [ ] Profile screen overflow menu - add "Social Graph" entry below "Profile Context" +- [ ] Route: `/social-graph?did={DID}` in `app_router.dart` + +### Tests + +- [ ] Unit tests: `SocialGraphRepository` - follows/followers fetching, cap enforcement, mutual detection +- [ ] Unit tests: `PdsResolver` - `did:plc` and `did:web` parsing, cache hit, error handling +- [ ] Unit tests: `ForceSimulation` - convergence, node pinning, cooling, edge weight effect +- [ ] Unit tests: `SocialGraphCubit` - state transitions, node selection, progressive loading +- [ ] Widget tests: screen renders graph canvas, legend chips, info card shows on tap with correct fields, "View Profile" navigates, loading/error states diff --git a/lib/core/network/constellation_client.dart b/lib/core/network/constellation_client.dart new file mode 100644 index 0000000..3d594de --- /dev/null +++ b/lib/core/network/constellation_client.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +const String _defaultBaseUrl = 'https://constellation.microcosm.blue'; +const Duration _kTimeout = Duration(seconds: 10); + +class ConstellationLinkRecord { + const ConstellationLinkRecord({required this.did, required this.collection, required this.rkey}); + + final String did; + final String collection; + final String rkey; + + factory ConstellationLinkRecord.fromJson(Map json) { + return ConstellationLinkRecord( + did: json['did'] as String, + collection: json['collection'] as String, + rkey: json['rkey'] as String, + ); + } +} + +class ManyToManyItem { + const ManyToManyItem({required this.linkRecord, required this.otherSubject}); + + final ConstellationLinkRecord linkRecord; + final String otherSubject; + + factory ManyToManyItem.fromJson(Map json) { + return ManyToManyItem( + linkRecord: ConstellationLinkRecord.fromJson(json['linkRecord'] as Map), + otherSubject: json['otherSubject'] as String, + ); + } +} + +class ConstellationException implements Exception { + const ConstellationException(this.message); + + final String message; + + @override + String toString() => 'ConstellationException: $message'; +} + +class ConstellationClient { + ConstellationClient({String? baseUrl, http.Client? httpClient}) + : _baseUrl = baseUrl ?? _defaultBaseUrl, + _httpClient = httpClient ?? http.Client(); + + final String _baseUrl; + final http.Client _httpClient; + + Uri _xrpcUri(String endpoint, Map params) { + final filtered = {}; + for (final entry in params.entries) { + if (entry.value != null) filtered[entry.key] = entry.value!; + } + final base = Uri.parse('$_baseUrl/xrpc/$endpoint'); + return filtered.isEmpty ? base : base.replace(queryParameters: filtered); + } + + Future> _get(Uri uri) async { + final response = await _httpClient.get(uri, headers: {'User-Agent': 'lazurite'}).timeout(_kTimeout); + + if (response.statusCode != 200) { + throw ConstellationException('HTTP ${response.statusCode}: ${response.body}'); + } + + return jsonDecode(response.body) as Map; + } + + Future getBacklinksCount(String subject, String source) async { + final uri = _xrpcUri('blue.microcosm.links.getBacklinksCount', {'subject': subject, 'source': source}); + final data = await _get(uri); + return data['total'] as int; + } + + Future<({int total, List dids, String? cursor})> getDistinct( + String subject, + String source, { + int? limit, + String? cursor, + }) async { + final uri = _xrpcUri('blue.microcosm.links.getDistinct', { + 'subject': subject, + 'source': source, + if (limit != null) 'limit': limit.toString(), + 'cursor': cursor, + }); + final data = await _get(uri); + return ( + total: data['total'] as int, + dids: (data['dids'] as List).cast(), + cursor: data['cursor'] as String?, + ); + } + + Future<({int total, List records, String? cursor})> getBacklinks( + String subject, + String source, { + int? limit, + String? cursor, + }) async { + final uri = _xrpcUri('blue.microcosm.links.getBacklinks', { + 'subject': subject, + 'source': source, + if (limit != null) 'limit': limit.toString(), + 'cursor': cursor, + }); + final data = await _get(uri); + final records = (data['linking_records'] as List) + .map((r) => ConstellationLinkRecord.fromJson(r as Map)) + .toList(); + return (total: data['total'] as int, records: records, cursor: data['cursor'] as String?); + } + + Future<({List items, String? cursor})> getManyToMany( + String subject, + String source, + String pathToOther, { + int? limit, + String? cursor, + }) async { + final uri = _xrpcUri('blue.microcosm.links.getManyToMany', { + 'subject': subject, + 'source': source, + 'pathToOther': pathToOther, + if (limit != null) 'limit': limit.toString(), + 'cursor': cursor, + }); + final data = await _get(uri); + final items = (data['items'] as List) + .map((i) => ManyToManyItem.fromJson(i as Map)) + .toList(); + return (items: items, cursor: data['cursor'] as String?); + } +} diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index 0142b4a..58fddc8 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -13,6 +13,7 @@ class SettingsCubit extends Cubit { FeedLayout? initialFeedLayout, bool? initialSimulateOffline, int? initialThreadAutoCollapseDepth, + String? initialConstellationUrl, }) : super( SettingsState( themePalette: initialPalette ?? AppThemePalette.oxocarbon, @@ -21,6 +22,7 @@ class SettingsCubit extends Cubit { feedLayout: initialFeedLayout ?? FeedLayout.card, simulateOffline: initialSimulateOffline ?? false, threadAutoCollapseDepth: initialThreadAutoCollapseDepth, + constellationUrl: initialConstellationUrl ?? 'https://constellation.microcosm.blue', ), ); @@ -33,6 +35,8 @@ class SettingsCubit extends Cubit { static const String _legacyKeyFeedArchitecture = 'feed_architecture'; static const String _keySimulateOffline = 'simulate_offline'; static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; + static const String _keyConstellationUrl = 'constellation_url'; + static const String _defaultConstellationUrl = 'https://constellation.microcosm.blue'; Future loadSettings() async { final paletteStr = await database.getSetting(_keyThemePalette); @@ -42,6 +46,7 @@ class SettingsCubit extends Cubit { await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); final simulateOfflineStr = await database.getSetting(_keySimulateOffline); final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); + final constellationUrlStr = await database.getSetting(_keyConstellationUrl); emit( state.copyWith( @@ -51,6 +56,7 @@ class SettingsCubit extends Cubit { feedLayout: FeedLayout.fromString(feedLayoutStr), simulateOffline: simulateOfflineStr == 'true', threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), + constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, ), ); } @@ -95,4 +101,9 @@ class SettingsCubit extends Cubit { } emit(state.copyWith(threadAutoCollapseDepth: depth)); } + + Future setConstellationUrl(String url) async { + await database.setSetting(_keyConstellationUrl, url); + emit(state.copyWith(constellationUrl: url)); + } } diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 6fb487b..c36697c 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -12,6 +12,7 @@ class SettingsState extends Equatable { this.feedLayout = FeedLayout.card, this.simulateOffline = false, this.threadAutoCollapseDepth, + this.constellationUrl = 'https://constellation.microcosm.blue', }); final AppThemePalette themePalette; @@ -20,6 +21,7 @@ class SettingsState extends Equatable { final FeedLayout feedLayout; final bool simulateOffline; final int? threadAutoCollapseDepth; + final String constellationUrl; SettingsState copyWith({ AppThemePalette? themePalette, @@ -28,6 +30,7 @@ class SettingsState extends Equatable { FeedLayout? feedLayout, bool? simulateOffline, Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, + String? constellationUrl, }) { return SettingsState( themePalette: themePalette ?? this.themePalette, @@ -38,6 +41,7 @@ class SettingsState extends Equatable { threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) ? this.threadAutoCollapseDepth : threadAutoCollapseDepth as int?, + constellationUrl: constellationUrl ?? this.constellationUrl, ); } @@ -49,5 +53,6 @@ class SettingsState extends Equatable { feedLayout, simulateOffline, threadAutoCollapseDepth, + constellationUrl, ]; } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 3a743bb..d1f768a 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -87,6 +87,9 @@ class SettingsScreen extends StatelessWidget { onTap: () => context.push('/saved'), ), const SizedBox(height: 24), + _buildSectionHeader(context, 'Advanced'), + _buildAdvancedSettings(context), + const SizedBox(height: 24), if (!kReleaseMode) ...[ _buildSectionHeader(context, 'Developer'), _buildDeveloperSettings(context), @@ -258,6 +261,26 @@ class SettingsScreen extends StatelessWidget { ); } + Widget _buildAdvancedSettings(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + color: Theme.of(context).cardColor, + ), + child: _ConstellationUrlTile( + currentUrl: state.constellationUrl, + onChanged: (url) => context.read().setConstellationUrl(url), + ), + ); + }, + ); + } + Widget _buildDeveloperSettings(BuildContext context) { final settingsCubit = context.read(); @@ -513,6 +536,47 @@ class _ConnectionDetailRow extends StatelessWidget { } } +class _ConstellationUrlTile extends StatelessWidget { + const _ConstellationUrlTile({required this.currentUrl, required this.onChanged}); + + final String currentUrl; + final ValueChanged onChanged; + + Future _showEditDialog(BuildContext context) async { + final controller = TextEditingController(text: currentUrl); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Constellation URL'), + content: TextField( + controller: controller, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration(hintText: 'https://constellation.microcosm.blue'), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(context).pop(controller.text.trim()), child: const Text('Save')), + ], + ), + ); + if (result != null && result.isNotEmpty) { + onChanged(result); + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('Constellation URL'), + subtitle: Text(currentUrl, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _showEditDialog(context), + ); + } +} + class _SettingsTile extends StatelessWidget { const _SettingsTile({ required this.title, diff --git a/test/core/network/constellation_client_test.dart b/test/core/network/constellation_client_test.dart new file mode 100644 index 0000000..f261060 --- /dev/null +++ b/test/core/network/constellation_client_test.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; + +void main() { + group('ConstellationClient', () { + group('URL construction', () { + test('uses default base URL when none provided', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); + + expect(capturedUri?.host, 'constellation.microcosm.blue'); + expect(capturedUri?.scheme, 'https'); + }); + + test('uses custom base URL when provided', () async { + Uri? capturedUri; + final client = ConstellationClient( + baseUrl: 'https://my.instance.example', + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); + + expect(capturedUri?.host, 'my.instance.example'); + }); + + test('builds XRPC path correctly', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); + + expect(capturedUri?.path, '/xrpc/blue.microcosm.links.getBacklinksCount'); + }); + + test('sends User-Agent: lazurite header', () async { + Map? capturedHeaders; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedHeaders = request.headers; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:test', 'app.bsky.graph.block:subject'); + + expect(capturedHeaders?['User-Agent'], 'lazurite'); + }); + }); + + group('getBacklinksCount', () { + test('returns total from response', () async { + final client = ConstellationClient( + httpClient: MockClient((_) async => http.Response(jsonEncode({'total': 42}), 200)), + ); + + final result = await client.getBacklinksCount('did:plc:abc', 'app.bsky.graph.block:subject'); + + expect(result, 42); + }); + + test('encodes subject and source as query parameters', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0}), 200); + }), + ); + + await client.getBacklinksCount('did:plc:abc123', 'app.bsky.graph.block:subject'); + + expect(capturedUri?.queryParameters['subject'], 'did:plc:abc123'); + expect(capturedUri?.queryParameters['source'], 'app.bsky.graph.block:subject'); + }); + + test('throws ConstellationException on non-200 response', () async { + final client = ConstellationClient( + httpClient: MockClient((_) async => http.Response('Internal Server Error', 500)), + ); + + expect(() => client.getBacklinksCount('did:plc:abc', 'source'), throwsA(isA())); + }); + + test('throws on timeout', () async { + final client = ConstellationClient( + httpClient: MockClient((_) async { + await Future.delayed(const Duration(seconds: 15)); + return http.Response('', 200); + }), + ); + + expect(() => client.getBacklinksCount('did:plc:abc', 'source'), throwsA(isA())); + }, timeout: const Timeout(Duration(seconds: 20))); + }); + + group('getDistinct', () { + test('returns total, dids and cursor from response', () async { + final responseBody = jsonEncode({ + 'total': 3, + 'dids': ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], + 'cursor': 'next-cursor', + }); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getDistinct('did:plc:abc', 'app.bsky.graph.block:subject'); + + expect(result.total, 3); + expect(result.dids, ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc']); + expect(result.cursor, 'next-cursor'); + }); + + test('returns null cursor when absent', () async { + final responseBody = jsonEncode({ + 'total': 1, + 'dids': ['did:plc:aaa'], + }); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getDistinct('did:plc:abc', 'source'); + + expect(result.cursor, isNull); + }); + + test('passes limit and cursor as query parameters', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); + }), + ); + + await client.getDistinct('did:plc:abc', 'source', limit: 25, cursor: 'some-cursor'); + + expect(capturedUri?.queryParameters['limit'], '25'); + expect(capturedUri?.queryParameters['cursor'], 'some-cursor'); + }); + + test('omits limit and cursor when not provided', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); + }), + ); + + await client.getDistinct('did:plc:abc', 'source'); + + expect(capturedUri?.queryParameters.containsKey('limit'), isFalse); + expect(capturedUri?.queryParameters.containsKey('cursor'), isFalse); + }); + + test('throws ConstellationException on non-200 response', () async { + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Not Found', 404))); + + expect(() => client.getDistinct('did:plc:abc', 'source'), throwsA(isA())); + }); + }); + + group('getBacklinks', () { + test('returns total, records and cursor from response', () async { + final responseBody = jsonEncode({ + 'total': 2, + 'linking_records': [ + {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rkey1'}, + {'did': 'did:plc:bbb', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rkey2'}, + ], + 'cursor': 'cursor-xyz', + }); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getBacklinks('did:plc:abc', 'app.bsky.graph.listitem:subject'); + + expect(result.total, 2); + expect(result.records.length, 2); + expect(result.records.first.did, 'did:plc:aaa'); + expect(result.records.first.collection, 'app.bsky.graph.listitem'); + expect(result.records.first.rkey, 'rkey1'); + expect(result.cursor, 'cursor-xyz'); + }); + + test('returns empty records list when none returned', () async { + final responseBody = jsonEncode({'total': 0, 'linking_records': []}); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getBacklinks('did:plc:abc', 'source'); + + expect(result.records, isEmpty); + expect(result.cursor, isNull); + }); + + test('passes limit and cursor as query parameters', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'total': 0, 'linking_records': []}), 200); + }), + ); + + await client.getBacklinks('did:plc:abc', 'source', limit: 50, cursor: 'page2'); + + expect(capturedUri?.queryParameters['limit'], '50'); + expect(capturedUri?.queryParameters['cursor'], 'page2'); + }); + + test('throws ConstellationException on error response', () async { + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Unauthorized', 401))); + + expect(() => client.getBacklinks('did:plc:abc', 'source'), throwsA(isA())); + }); + }); + + group('getManyToMany', () { + test('returns items and cursor from response', () async { + final responseBody = jsonEncode({ + 'items': [ + { + 'linkRecord': {'did': 'did:plc:aaa', 'collection': 'app.bsky.graph.listitem', 'rkey': 'rk1'}, + 'otherSubject': 'at://did:plc:aaa/app.bsky.graph.list/rk2', + }, + ], + 'cursor': 'next', + }); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getManyToMany('did:plc:abc', 'app.bsky.graph.listitem:subject', 'list'); + + expect(result.items.length, 1); + expect(result.items.first.linkRecord.did, 'did:plc:aaa'); + expect(result.items.first.otherSubject, 'at://did:plc:aaa/app.bsky.graph.list/rk2'); + expect(result.cursor, 'next'); + }); + + test('passes pathToOther as query parameter', () async { + Uri? capturedUri; + final client = ConstellationClient( + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'items': [], 'cursor': null}), 200); + }), + ); + + await client.getManyToMany('did:plc:abc', 'source', 'list'); + + expect(capturedUri?.queryParameters['pathToOther'], 'list'); + expect(capturedUri?.path, '/xrpc/blue.microcosm.links.getManyToMany'); + }); + + test('returns null cursor when absent', () async { + final responseBody = jsonEncode({'items': []}); + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); + + final result = await client.getManyToMany('did:plc:abc', 'source', 'list'); + + expect(result.cursor, isNull); + }); + + test('throws ConstellationException on error response', () async { + final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Bad Request', 400))); + + expect(() => client.getManyToMany('did:plc:abc', 'source', 'list'), throwsA(isA())); + }); + }); + + group('ConstellationException', () { + test('toString includes message', () { + const e = ConstellationException('HTTP 500: server error'); + expect(e.toString(), contains('HTTP 500: server error')); + }); + }); + + group('ConstellationLinkRecord.fromJson', () { + test('parses all fields', () { + final record = ConstellationLinkRecord.fromJson({ + 'did': 'did:plc:xyz', + 'collection': 'app.bsky.graph.listitem', + 'rkey': 'abc123', + }); + expect(record.did, 'did:plc:xyz'); + expect(record.collection, 'app.bsky.graph.listitem'); + expect(record.rkey, 'abc123'); + }); + }); + + group('ManyToManyItem.fromJson', () { + test('parses linkRecord and otherSubject', () { + final item = ManyToManyItem.fromJson({ + 'linkRecord': {'did': 'did:plc:a', 'collection': 'col', 'rkey': 'rk'}, + 'otherSubject': 'at://did:plc:a/app.bsky.graph.list/rk2', + }); + expect(item.linkRecord.did, 'did:plc:a'); + expect(item.otherSubject, 'at://did:plc:a/app.bsky.graph.list/rk2'); + }); + }); + }); +} diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index 28620c0..cc44cd1 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -219,5 +219,53 @@ void main() { .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), ], ); + + test('initial state has default constellation URL', () { + final cubit = SettingsCubit(database: database); + expect(cubit.state.constellationUrl, 'https://constellation.microcosm.blue'); + }); + + test('accepts initial constellation URL via constructor', () { + final cubit = SettingsCubit(database: database, initialConstellationUrl: 'https://my.instance.example'); + expect(cubit.state.constellationUrl, 'https://my.instance.example'); + }); + + blocTest( + 'setConstellationUrl updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setConstellationUrl('https://custom.example.com'), + expect: () => [ + isA().having((s) => s.constellationUrl, 'constellationUrl', 'https://custom.example.com'), + ], + verify: (cubit) async { + final value = await database.getSetting('constellation_url'); + expect(value, 'https://custom.example.com'); + }, + ); + + blocTest( + 'loadSettings loads persisted constellation URL', + build: () => SettingsCubit(database: database), + setUp: () async { + await database.setSetting('constellation_url', 'https://self-hosted.example'); + }, + act: (cubit) => cubit.loadSettings(), + expect: () => [ + isA().having((s) => s.constellationUrl, 'constellationUrl', 'https://self-hosted.example'), + ], + ); + + blocTest( + 'loadSettings uses default constellation URL when not persisted', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.loadSettings(), + expect: () => [ + isA().having( + (s) => s.constellationUrl, + 'constellationUrl', + 'https://constellation.microcosm.blue', + ), + ], + ); }); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 523c35f..388fabb 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -149,6 +149,33 @@ void main() { expect(find.text('Email Notifications'), findsNothing); expect(find.text('Help & Support'), findsNothing); }); + + testWidgets('shows Advanced section with Constellation URL tile', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('ADVANCED'), 300); + await tester.pumpAndSettle(); + + expect(find.text('ADVANCED'), findsOneWidget); + expect(find.text('Constellation URL'), findsOneWidget); + expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); + }); + + testWidgets('Constellation URL tile opens edit dialog on tap', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Constellation URL'), 300); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Constellation URL')); + await tester.pumpAndSettle(); + + expect(find.text('Constellation URL'), findsWidgets); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); } String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { -- 2.51.2