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.

refactor: change some wording

* update changelog

+46 -143
+9 -1
CHANGELOG.md
··· 43 43 44 44 #### 2026-03-22 45 45 46 - - Starter packs & lists 46 + - Starter pack & list views 47 + 48 + #### 2026-04-11 49 + 50 + - Follow hygiene feature to identify and unfollow inactive or problematic accounts 51 + 52 + #### 2026-04-12 53 + 54 + - Multiple account support (controlled from settings and sidebar/menu)
+4
docs/TODO.md
··· 1 1 # To-Do/Parking Lot 2 2 3 + ## Tests 4 + 5 + - Integration test: save a post → verify it appears in semantic search results for a relevant query 6 + 3 7 ## UI 4 8 5 9 - Show feed icons in the feed management UI
+27 -27
docs/designs/login.html
··· 18 18 padding: 24px; 19 19 background: linear-gradient(180deg, var(--bg) 0%, var(--surface) 100%); 20 20 } 21 - 21 + 22 22 .login-header { 23 23 text-align: center; 24 24 margin-bottom: 48px; 25 25 } 26 - 26 + 27 27 .logo { 28 28 width: 80px; 29 29 height: 80px; ··· 35 35 justify-content: center; 36 36 box-shadow: 0 8px 32px rgba(0, 102, 255, 0.25); 37 37 } 38 - 38 + 39 39 .logo svg { 40 40 width: 44px; 41 41 height: 44px; 42 42 color: white; 43 43 } 44 - 44 + 45 45 .app-name { 46 46 font-size: 32px; 47 47 font-weight: 700; ··· 49 49 margin-bottom: 8px; 50 50 letter-spacing: -0.5px; 51 51 } 52 - 52 + 53 53 .app-tagline { 54 54 color: var(--text-secondary); 55 55 font-size: 15px; 56 56 } 57 - 57 + 58 58 .login-methods { 59 59 display: flex; 60 60 flex-direction: column; 61 61 gap: 16px; 62 62 margin-bottom: 32px; 63 63 } 64 - 64 + 65 65 .divider-with-text { 66 66 display: flex; 67 67 align-items: center; ··· 72 72 font-weight: 500; 73 73 text-transform: uppercase; 74 74 } 75 - 75 + 76 76 .divider-with-text::before, 77 77 .divider-with-text::after { 78 78 content: ''; ··· 80 80 height: 1px; 81 81 background-color: var(--border); 82 82 } 83 - 83 + 84 84 .debug-section { 85 85 background-color: var(--surface); 86 86 border-radius: 12px; 87 87 padding: 20px; 88 88 border: 1.5px dashed var(--border); 89 89 } 90 - 90 + 91 91 .debug-header { 92 92 display: flex; 93 93 align-items: center; 94 94 gap: 8px; 95 95 margin-bottom: 16px; 96 96 } 97 - 97 + 98 98 .debug-title { 99 99 font-weight: 600; 100 100 color: var(--text-primary); 101 101 font-size: 15px; 102 102 } 103 - 103 + 104 104 .debug-form { 105 105 display: flex; 106 106 flex-direction: column; 107 107 gap: 12px; 108 108 } 109 - 109 + 110 110 .input-group { 111 111 display: flex; 112 112 flex-direction: column; 113 113 gap: 6px; 114 114 } 115 - 115 + 116 116 .input-label { 117 117 font-size: 13px; 118 118 font-weight: 500; 119 119 color: var(--text-secondary); 120 120 text-transform: uppercase; 121 121 } 122 - 122 + 123 123 .help-text { 124 124 font-size: 12px; 125 125 color: var(--text-muted); 126 126 margin-top: 8px; 127 127 text-align: center; 128 128 } 129 - 129 + 130 130 .login-footer { 131 131 text-align: center; 132 132 margin-top: auto; 133 133 padding-top: 32px; 134 134 } 135 - 135 + 136 136 .terms-text { 137 137 font-size: 12px; 138 138 color: var(--text-muted); ··· 153 153 </svg> 154 154 </div> 155 155 <h1 class="app-name">Lazurite</h1> 156 - <p class="app-tagline">Connect with BlueSky</p> 156 + <p class="app-tagline">Roam the ATmosphere</p> 157 157 </div> 158 - 158 + 159 159 <!-- Login Methods --> 160 160 <div class="login-methods"> 161 161 <!-- OAuth 2.0 Login --> ··· 163 163 <svg viewBox="0 0 24 24" fill="currentColor"> 164 164 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/> 165 165 </svg> 166 - Continue with BlueSky OAuth 166 + Continue to BlueSky 167 167 </button> 168 - 168 + 169 169 <div class="divider-with-text">Or</div> 170 - 170 + 171 171 <!-- Debug Mode - App Password --> 172 172 <div class="debug-section"> 173 173 <div class="debug-header"> 174 174 <span class="debug-badge">Debug Mode</span> 175 175 <span class="debug-title">App Password Login</span> 176 176 </div> 177 - 177 + 178 178 <form class="debug-form"> 179 179 <div class="input-group"> 180 180 <label class="input-label">Handle or DID</label> 181 181 <input type="text" class="input" placeholder="@username.bsky.social"> 182 182 </div> 183 - 183 + 184 184 <div class="input-group"> 185 185 <label class="input-label">App Password</label> 186 186 <input type="password" class="input" placeholder="xxxx-xxxx-xxxx-xxxx"> 187 187 </div> 188 - 188 + 189 189 <button type="submit" class="btn btn-primary"> 190 190 Sign In 191 191 </button> 192 192 </form> 193 - 193 + 194 194 <p class="help-text"> 195 195 App passwords can be generated in BlueSky Settings → App Passwords 196 196 </p> 197 197 </div> 198 198 </div> 199 - 199 + 200 200 <!-- Footer --> 201 201 <div class="login-footer"> 202 202 <p class="terms-text">
+1 -8
docs/tasks/phase-4.md
··· 10 10 11 11 ## M14 — Account Switching 12 12 13 - - [x] `AccountSwitcherCubit` exposing account list and active DID 14 - - [x] Account switcher bottom sheet UI — list accounts with avatars and handles 15 - - [x] Store `active_account_did` in Drift `settings` table 16 - - [x] Drift migration: add `account_did` column to `cached_posts` if not present 17 - - [x] All user-scoped queries filter by active account DID 18 - - [x] Broadcast `AccountSwitched` event to all Blocs on switch 19 - - [x] "Add Account" button triggers OAuth flow, inserts new `accounts` row 20 - - [x] Silent token refresh on account switch; navigate to login on failure 13 + Completed [2026-04-12](../../CHANGELOG.md#2026-04-12) 21 14 22 15 ## M15 — Offline Reading & Network Resilience 23 16
-1
docs/tasks/phase-7.md
··· 100 100 - [x] Unit tests: `SemanticIndexCubit` - backfill progress, reindex trigger 101 101 - [x] Widget tests: search tab renders, query produces results, scope chips filter, relevance badges display, empty/no-results/unavailable states 102 102 - [x] Widget tests: settings section renders, toggle enables/disables, progress indicator during backfill, re-index button triggers reindex 103 - - [ ] Integration test: save a post → verify it appears in semantic search results for a relevant query
+1 -102
docs/tasks/phase-8.md
··· 5 5 6 6 ## M27 - Follow Hygiene: Detect & Remove Inactive/Problematic Follows 7 7 8 - ### Core 9 - 10 - #### Models 11 - 12 - - [x] `FollowStatus` enum — `deleted`, `deactivated`, `suspended`, `blockedBy`, `blocking`, `mutualBlock`, `hidden`, `selfFollow` 13 - - [x] `FollowRecord` model — `uri`, `rkey`, `subjectDid`; extracted from `com.atproto.repo.listRecords` response 14 - - [x] `ClassifiedFollow` model — `record` (FollowRecord), `handle`, `status` (FollowStatus), `statusLabel`, `selected` (mutable); `Equatable` for state comparison (excluding `selected`) 15 - 16 - #### Repository 17 - 18 - - [x] `FollowAuditRepository` — new file `lib/features/profile/data/follow_audit_repository.dart`, depends on authenticated `Bluesky` client 19 - - [x] `fetchAllFollows(String did)` — paginate `atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.follow', limit: 100)` with cursor until exhausted, return `List<FollowRecord>` 20 - - [x] `classifyFollows(List<FollowRecord>, String ownDid)` — batch `actor.getProfiles` (25/batch, 2 concurrent, 500ms inter-group delay), per-DID `getProfile` fallback for missing entries, classify each by `FollowStatus`, return `(List<ClassifiedFollow> results, int failedCount)` 21 - - [x] `batchUnfollow(List<ClassifiedFollow>)` — extract rkeys, build `applyWrites#delete` operations, chunk into batches of 200, execute sequentially, return count of successfully deleted records 22 - - [x] Retry logic — on 429 or network error during `getProfiles`/`getProfile`, exponential backoff (1s/2s/4s), max 3 retries per batch 23 - 24 - #### Cubit 25 - 26 - - [x] `FollowAuditState` — `status` (initial/fetching/classifying/ready/unfollowing/complete/error), `results`, `totalFollows`, `progress`, `failedProfiles`, `unfollowedCount`, `errorMessage`, `visibleStatuses` 27 - - [x] `FollowAuditCubit` — depends on `FollowAuditRepository`, authenticated DID 28 - - [x] `audit()` — orchestrates fetch → classify → ready, emits progress updates during each phase 29 - - [x] `toggleSelection(int index)` — toggle individual record selection 30 - - [x] `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — bulk select/deselect by category 31 - - [x] `toggleVisibility(FollowStatus)` — show/hide category in results list 32 - - [x] `confirmUnfollow()` — call `batchUnfollow` with selected records, emit unfollowing → complete, clear unfollowed records from results 33 - 34 - ### UI 35 - 36 - #### Follow Audit Screen 37 - 38 - - [x] `FollowAuditScreen` — new file `lib/features/profile/presentation/follow_audit_screen.dart` 39 - - [x] Header — "Clean Follows" title, subtitle with total follow count 40 - - [x] Action bar — "Scan" button (initial) → "Unfollow Selected (N)" button (ready), disabled during loading states 41 - - [x] Linear progress bar — during fetch/classify, shows "Fetching follows: X/Y" or "Classifying: X/Y" 42 - - [x] Failed profiles warning — amber text below progress bar when `failedProfiles > 0` 43 - - [x] Results list — checkbox, handle (tappable → navigate to profile via GoRouter), truncated DID, status badge chip. Selected rows get destructive-red background tint 44 - - [x] Empty state — "No problematic follows found" when audit completes with 0 results 45 - - [x] Complete state — "Unfollowed N account(s)" after successful batch delete 46 - - [x] Error state — error message with "Retry" button 47 - 48 - #### Filter Controls 49 - 50 - - [x] Responsive layout — horizontal scrollable chip row on narrow screens (`< 600px`), sticky sidebar on wider screens 51 - - [x] Per-status filter tile — visibility toggle (show/hide rows of that status in list) + "Select All" checkbox 52 - - [x] Category count badges — show count of results per status category 53 - - [x] Summary line — "Selected: N/M" count, always visible 54 - 55 - #### Navigation & Entry Points 56 - 57 - - [x] Settings screen — new "Account Maintenance" section with "Clean Follows" tile, navigates to `FollowAuditScreen` 58 - - [x] Profile screen overflow menu — add "Clean Follows" option when viewing own profile, navigates to `FollowAuditScreen` 59 - - [x] GoRouter route — `/settings/clean-follows` 60 - 61 - ### Tests 62 - 63 - #### Unit Tests — Models 64 - 65 - - [x] `FollowRecord` — construction, rkey extraction from AT URI 66 - - [x] `ClassifiedFollow` — construction, statusLabel mapping for each `FollowStatus` value 67 - - [x] `FollowStatus` — verify all enum values exist and labels are correct 68 - 69 - #### Unit Tests — Repository 70 - 71 - - [x] `fetchAllFollows` — single page (< 100 records), multi-page pagination (cursor handling), empty follows list 72 - - [x] `classifyFollows` — deleted account (getProfile returns "not found"), deactivated account, suspended account 73 - - [x] `classifyFollows` — blocked-by (viewer.blockedBy), blocking (viewer.blocking), mutual block (both), hidden (!hide label), self-follow 74 - - [x] `classifyFollows` — batch hydration: profiles returned in getProfiles are classified correctly, missing profiles fall through to per-DID lookup 75 - - [x] `classifyFollows` — partial failure: some batches fail, returns results for successful batches + failedCount 76 - - [x] `classifyFollows` — rate limit retry: mock 429 response, verify retry with backoff 77 - - [x] `batchUnfollow` — single batch (< 200 records), multi-batch chunking, empty selection (no-op) 78 - - [x] `batchUnfollow` — partial failure: first batch succeeds, second fails, returns partial count 79 - 80 - #### Unit Tests — Cubit 81 - 82 - - [x] `audit()` — state transitions: initial → fetching → classifying → ready 83 - - [x] `audit()` — progress updates emitted during fetch and classify phases 84 - - [x] `audit()` — error during fetch: initial → fetching → error 85 - - [x] `audit()` — empty results: transitions to ready with empty list 86 - - [x] `toggleSelection` — toggles selected flag on correct index, emits new state 87 - - [x] `selectAllByStatus` / `deselectAllByStatus` — selects/deselects all records matching status 88 - - [x] `toggleVisibility` — adds/removes status from visibleStatuses set 89 - - [x] `confirmUnfollow` — state transitions: ready → unfollowing → complete, unfollowed records removed from results 90 - - [x] `confirmUnfollow` — error during unfollow: ready → unfollowing → error with partial count 91 - 92 - #### Widget Tests (FollowAuditScreen) 93 - 94 - - [x] initial state renders "Scan" button 95 - - [x] fetching state shows progress bar with count text 96 - - [x] ready state renders results list with correct status badges 97 - - [x] selecting a record changes row background to red tint 98 - - [x] "Unfollow Selected" button shows correct count and is disabled when nothing selected 99 - - [x] filter toggles hide/show rows by status 100 - - [x] "Select All" per category selects all visible records of that status 101 - - [x] complete state shows "Unfollowed N account(s)" 102 - - [x] error state shows message and retry button 103 - - [x] empty results shows "No problematic follows found" 104 - - [x] tapping handle navigates to profile screen 105 - - [x] responsive layout: chips on narrow, sidebar on wide 106 - 107 - #### Integration Tests 108 - 109 - - [x] End-to-end: scan follows → results displayed → select records → confirm unfollow → success state 8 + Completed [2026-04-11](../../CHANGELOG.md#2026-04-11)
+2 -2
lib/features/auth/presentation/login_screen.dart
··· 87 87 ), 88 88 const SizedBox(height: 8), 89 89 Text( 90 - 'Connect with BlueSky', 90 + 'Roam the ATmosphere', 91 91 textAlign: TextAlign.center, 92 92 style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), 93 93 ), ··· 121 121 child: CircularProgressIndicator(strokeWidth: 2), 122 122 ) 123 123 : const Icon(Icons.language), 124 - label: Text(state.isLoading ? 'Starting sign in...' : 'Continue with BlueSky OAuth'), 124 + label: Text(state.isLoading ? 'Starting sign in...' : 'Continue to BlueSky'), 125 125 style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), 126 126 ); 127 127 },
+2 -2
test/core/router/app_router_test.dart
··· 7 7 import 'package:flutter_test/flutter_test.dart'; 8 8 import 'package:lazurite/core/router/app_router.dart'; 9 9 import 'package:lazurite/core/theme/app_theme.dart'; 10 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 10 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 12 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 16 17 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 17 18 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 18 19 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 19 - import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 20 20 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 21 21 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 22 22 import 'package:mocktail/mocktail.dart'; ··· 345 345 await tester.pump(); 346 346 await tester.pumpAndSettle(); 347 347 348 - expect(find.text('Continue with BlueSky OAuth'), findsOneWidget); 348 + expect(find.text('Continue to BlueSky'), findsOneWidget); 349 349 expect(tester.takeException(), isNull); 350 350 351 351 router.dispose();