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: update settings copy for providers

+135 -103
+8 -4
CHANGELOG.md
··· 1 1 # CHANGELOG 2 2 3 - ## v0.1.0 (Unreleased) 3 + ## v1.0.0 (Unreleased) 4 4 5 5 ### Added 6 6 ··· 66 66 #### 2026-04-14 67 67 68 68 - Post editing via delete-recreate 69 - - Added url resolution for in-app links (profiles, posts, hashtags) with dedicated hashtag 70 - screen (matches bsky.app implementation with Top/Latest sorting) 69 + - Added url resolution for in-app links (profiles, posts, hashtags) with dedicated 70 + hashtag screen (matches bsky.app implementation with Top/Latest sorting) 71 71 - Jump to hashtag action from the hashtag screen with related hashtags & search 72 72 73 73 #### 2026-04-28 ··· 81 81 82 82 #### 2026-04-30 83 83 84 - - Added shades of purple/blacksky inspired theme 84 + - Added [shades of purple](https://github.com/Rigellute/shades-of-purple.vim)/[blacksky](https://blacksky.community) 85 + inspired theme 86 + - AppView (BlueSky or BlackSky) based routing with swappable provider from Login or 87 + Settings 88 + - Trending views and feeds/listings based on AppView.
+1 -62
docs/tasks/routing.md
··· 3 3 updated: 2026-04-30 4 4 --- 5 5 6 - ## M1 - Core Routing Model 7 - 8 - - [x] Add `appview_provider` setting with defaults and validation 9 - - [x] Add login-screen provider selector (Bluesky + Blacksky visible by default) 10 - - [x] Persist login-screen provider choice before any auth/network call 11 - - [x] Add provider descriptor (`serviceDid`, `publicBaseUrl`, `entrywayUrl`, `webBaseUrl`) 12 - - [x] Add `AppViewRouter` abstraction for endpoint/header/link resolution 13 - - [x] Add unit tests for provider normalization/defaults and bootstrap ordering 14 - 15 - ## M2 - Header + Request Integration 16 - 17 - - [x] Inject explicit `atproto-proxy` for authenticated `app.bsky.*` 18 - - [x] Route signed-out public `app.bsky.*` reads via selected provider host 19 - - [x] Ensure `com.atproto.*` bypasses AppView routing 20 - 21 - ## M3 - Trending Surface 22 - 23 - - [x] Add Home app bar `Trending` action button 24 - - [x] Add `/trending` route and `TrendingScreen` 25 - - [x] Implement `getTrendingTopics(limit=10)` fetch path 26 - - [x] Implement required `getTrends(limit=10)` enrichment path for richer metadata 27 - - [x] Hide `Suggested` section when provider returns empty list 28 - - [x] Add loading/empty/error states for trending screen 29 - - [x] Add analytics/logging for provider and fallback used on trending requests 30 - 31 - ## M4 - Trend Link Routing 32 - 33 - - [x] Add provider-aware trend link resolver (`resolveWebLink`) 34 - - [x] Support `/profile/<actor>/feed/<rkey>` links 35 - - [x] Support `/topic/<id>` links 36 - - [x] Degrade unknown link formats to safe external open 37 - - [x] Add unit tests for link parsing and fallback resolution 38 - 39 - ## M5 - Fallback Engine 40 - 41 - - [x] Add user setting for cross-provider fallback (default off) 42 - - [x] Implement bounded fallback chain for read-only public endpoints 43 - - [x] Add circuit-breaker window per provider/endpoint 44 - - [x] Add structured logs for provider/fallback decisions 45 - - [x] Add tests for timeout/429/5xx transitions with fallback enabled/disabled 46 - 47 - ## M6 - microcosm Fallbacks 48 - 49 - - [x] Keep Constellation fallback paths first-class for backlink enrichments 50 - - [x] Add setting-gated Slingshot identity fallback for degraded handle resolution 51 - - [x] Add tests for fallback parsing and failure handling 52 - - [x] Document opt-in behavior and trust boundaries 53 - 54 - ## M7 - Settings and UX 55 - 56 - - [x] Add AppView provider controls in Settings (Bluesky/Blacksky) 57 - - [x] Add provider-change confirmation that performs app soft restart 58 - - [x] Confirm reset copy: stay signed in, no local data deletion 59 - - [x] Show concise warning about moderation/ranking/provider differences 60 - - [x] Add diagnostics view (active provider, last fallback, last error) 61 - - [x] Add manual `Refresh Provider Health` action 62 - 63 - ## M8 - Auth + Reset Safety 64 - 65 - - [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks 66 - - [x] Ensure provider switch rebuilds DI/blocs/services before new requests 67 - - [x] Add routing epoch/version guard to drop stale pre-reset responses 6 + Completed [2026-04-30](../../CHANGELOG.md#2026-04-30)
+7
lib/core/network/app_view_provider.dart
··· 61 61 } 62 62 return null; 63 63 } 64 + 65 + static String providerDisplayName(String providerKey) { 66 + if (providerKey == AppViewProviders.blackskyKey) { 67 + return 'Blacksky'; 68 + } 69 + return 'Bluesky'; 70 + } 64 71 }
+35 -34
lib/features/settings/presentation/settings_screen.dart
··· 1 1 import 'dart:async'; 2 - import 'package:lazurite/core/theme/theme_extensions.dart'; 3 2 4 3 import 'package:flutter/foundation.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_bloc/flutter_bloc.dart'; 7 6 import 'package:go_router/go_router.dart'; 8 - import 'package:lazurite/core/network/atproto_host_resolver.dart'; 9 7 import 'package:lazurite/core/network/app_view_provider.dart'; 8 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 10 9 import 'package:lazurite/core/router/app_shell.dart'; 11 10 import 'package:lazurite/core/theme/app_theme.dart'; 12 11 import 'package:lazurite/core/theme/feed_layout.dart'; 12 + import 'package:lazurite/core/theme/theme_extensions.dart'; 13 13 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 14 14 import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 15 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; ··· 181 181 182 182 Widget _buildThemeSelector(BuildContext context) { 183 183 final settingsCubit = context.read<SettingsCubit>(); 184 + final theme = Theme.of(context); 184 185 185 186 return BlocBuilder<SettingsCubit, SettingsState>( 186 187 builder: (context, state) { 187 188 return Container( 188 189 decoration: BoxDecoration( 189 190 border: Border( 190 - top: BorderSide(color: Theme.of(context).dividerColor), 191 - bottom: BorderSide(color: Theme.of(context).dividerColor), 191 + top: BorderSide(color: theme.dividerColor), 192 + bottom: BorderSide(color: theme.dividerColor), 192 193 ), 193 - color: Theme.of(context).cardColor, 194 + color: theme.cardColor, 194 195 ), 195 196 child: Column( 196 197 children: [ ··· 199 200 child: Center( 200 201 child: SegmentedButton<_AppearanceMode>( 201 202 style: SegmentedButton.styleFrom( 202 - selectedBackgroundColor: context.colorScheme.primary, 203 - selectedForegroundColor: context.colorScheme.onPrimary, 203 + selectedBackgroundColor: theme.colorScheme.primary, 204 + selectedForegroundColor: theme.colorScheme.onPrimary, 204 205 ), 205 206 segments: const [ 206 207 ButtonSegment(value: _AppearanceMode.system, label: Text('System')), ··· 251 252 252 253 Widget _buildLayoutSettings(BuildContext context) { 253 254 final settingsCubit = context.read<SettingsCubit>(); 255 + final theme = Theme.of(context); 254 256 255 257 return BlocBuilder<SettingsCubit, SettingsState>( 256 258 builder: (context, state) { 257 259 return Container( 258 260 decoration: BoxDecoration( 259 261 border: Border( 260 - top: BorderSide(color: Theme.of(context).dividerColor), 261 - bottom: BorderSide(color: Theme.of(context).dividerColor), 262 + top: BorderSide(color: theme.dividerColor), 263 + bottom: BorderSide(color: theme.dividerColor), 262 264 ), 263 - color: Theme.of(context).cardColor, 265 + color: theme.cardColor, 264 266 ), 265 267 child: Column( 266 268 children: [ ··· 307 309 Widget _buildSearchSettings(BuildContext context) { 308 310 return BlocBuilder<SettingsCubit, SettingsState>( 309 311 builder: (context, settingsState) { 312 + final theme = Theme.of(context); 310 313 return Container( 311 314 decoration: BoxDecoration( 312 315 border: Border( 313 - top: BorderSide(color: Theme.of(context).dividerColor), 314 - bottom: BorderSide(color: Theme.of(context).dividerColor), 316 + top: BorderSide(color: theme.dividerColor), 317 + bottom: BorderSide(color: theme.dividerColor), 315 318 ), 316 - color: Theme.of(context).cardColor, 319 + color: theme.cardColor, 317 320 ), 318 321 child: Column( 319 322 children: [ ··· 323 326 subtitle: Text( 324 327 settingsState.typeaheadProvider == 'community' 325 328 ? 'Community (waow.tech) selected. Third-party service, works before login.' 326 - : 'Bluesky official endpoint selected. Requires login.', 329 + : 'Bluesky official endpoint selected.', 327 330 ), 328 331 ), 329 332 Padding( ··· 400 403 401 404 return BlocBuilder<SettingsCubit, SettingsState>( 402 405 builder: (context, state) { 406 + final theme = Theme.of(context); 403 407 return Container( 404 408 decoration: BoxDecoration( 405 409 border: Border( 406 - top: BorderSide(color: Theme.of(context).dividerColor), 407 - bottom: BorderSide(color: Theme.of(context).dividerColor), 410 + top: BorderSide(color: theme.dividerColor), 411 + bottom: BorderSide(color: theme.dividerColor), 408 412 ), 409 - color: Theme.of(context).cardColor, 413 + color: theme.cardColor, 410 414 ), 411 415 child: _SettingsTile( 412 416 icon: Icons.cloud_off_outlined, ··· 421 425 422 426 Widget _buildAdvancedSettings(BuildContext context) { 423 427 final settingsCubit = context.read<SettingsCubit>(); 428 + final theme = Theme.of(context); 424 429 return BlocBuilder<SettingsCubit, SettingsState>( 425 430 builder: (context, state) { 426 431 return Container( 427 432 decoration: BoxDecoration( 428 433 border: Border( 429 - top: BorderSide(color: Theme.of(context).dividerColor), 430 - bottom: BorderSide(color: Theme.of(context).dividerColor), 434 + top: BorderSide(color: theme.dividerColor), 435 + bottom: BorderSide(color: theme.dividerColor), 431 436 ), 432 - color: Theme.of(context).cardColor, 437 + color: theme.cardColor, 433 438 ), 434 439 child: Column( 435 440 children: [ ··· 475 480 _SettingsTile( 476 481 icon: Icons.alt_route_outlined, 477 482 title: 'Slingshot Identity Fallback', 478 - subtitle: 'Use Slingshot resolveMiniDoc for degraded handle resolution', 483 + subtitle: 'If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue', 479 484 trailing: Switch.adaptive( 480 485 value: state.slingshotIdentityFallbackEnabled, 481 486 onChanged: settingsCubit.setSlingshotIdentityFallbackEnabled, ··· 487 492 title: 'Provider Diagnostics', 488 493 subtitle: 'Moderation/ranking can differ by provider. Verify health and recent fallback state.', 489 494 ), 490 - _ConnectionDetailRow(label: 'Active Provider', value: _providerDisplayName(state.appViewProvider)), 495 + _ConnectionDetailRow( 496 + label: 'Active Provider', 497 + value: AppViewProviders.providerDisplayName(state.appViewProvider), 498 + ), 491 499 const Divider(height: 1), 492 500 _ConnectionDetailRow(label: 'Health', value: state.appViewHealthSummary ?? 'Not checked yet'), 493 501 const Divider(height: 1), ··· 522 530 ); 523 531 } 524 532 525 - String _providerDisplayName(String providerKey) { 526 - if (providerKey == AppViewProviders.blackskyKey) { 527 - return 'Blacksky'; 528 - } 529 - return 'Bluesky'; 530 - } 531 - 532 533 String _appViewSubtitle(String providerKey) { 533 - final provider = _providerDisplayName(providerKey); 534 + final provider = AppViewProviders.providerDisplayName(providerKey); 534 535 return '$provider selected. Switching providers performs a soft restart.'; 535 536 } 536 537 ··· 662 663 } 663 664 664 665 final pds = resolvePdsHost(tokens); 665 - 666 + final theme = Theme.of(context); 666 667 return Container( 667 668 decoration: BoxDecoration( 668 669 border: Border( 669 - top: BorderSide(color: Theme.of(context).dividerColor), 670 - bottom: BorderSide(color: Theme.of(context).dividerColor), 670 + top: BorderSide(color: theme.dividerColor), 671 + bottom: BorderSide(color: theme.dividerColor), 671 672 ), 672 - color: Theme.of(context).cardColor, 673 + color: theme.cardColor, 673 674 ), 674 675 child: SelectionArea( 675 676 child: Column(
+43 -2
lib/features/typeahead/data/typeahead_repository.dart
··· 40 40 41 41 static const String _communityHost = 'typeahead.waow.tech'; 42 42 static const String _communityPath = '/xrpc/app.bsky.actor.searchActorsTypeahead'; 43 + static const String _searchActorsTypeaheadEndpoint = 'app.bsky.actor.searchActorsTypeahead'; 43 44 44 45 final dynamic _bluesky; 45 46 final String? _provider; ··· 101 102 Future<List<TypeaheadResult>> _searchBluesky({required String query, required int limit}) async { 102 103 final bluesky = _bluesky; 103 104 if (bluesky == null) { 104 - throw StateError('Bluesky provider requires an authenticated Bluesky client.'); 105 + return _searchBlueskyPublicHttp(query: query, limit: limit); 105 106 } 106 107 107 108 final response = await bluesky.actor.searchActorsTypeahead( 108 109 q: query, 109 110 limit: limit, 110 111 $headers: _appViewContext.appBskyHeadersForEndpoint( 111 - 'app.bsky.actor.searchActorsTypeahead', 112 + _searchActorsTypeaheadEndpoint, 112 113 await _moderationService?.headersForRequest(), 113 114 ), 114 115 ); ··· 117 118 .whereType<ProfileViewBasic>() 118 119 .map(TypeaheadResult.fromProfileViewBasic) 119 120 .toList(growable: false); 121 + return _applyModeration(results); 122 + } 123 + 124 + Future<List<TypeaheadResult>> _searchBlueskyPublicHttp({required String query, required int limit}) async { 125 + final uri = Uri.https(_appViewContext.publicServiceHost(), '/xrpc/$_searchActorsTypeaheadEndpoint', { 126 + 'q': query, 127 + 'limit': limit.toString(), 128 + }); 129 + final headers = _appViewContext.appBskyHeadersForEndpoint(_searchActorsTypeaheadEndpoint, { 130 + 'X-Client': 'lazurite', 131 + ...?await _moderationService?.headersForRequest(), 132 + }); 133 + final response = await _httpClient.get(uri, headers: headers); 134 + if (response.statusCode < 200 || response.statusCode >= 300) { 135 + throw HttpException('Bluesky typeahead request failed: HTTP ${response.statusCode}', uri: uri); 136 + } 137 + 138 + final decoded = jsonDecode(response.body); 139 + if (decoded is! Map<String, dynamic>) { 140 + throw const FormatException('Bluesky typeahead response was not a JSON object.'); 141 + } 142 + 143 + final actors = decoded['actors']; 144 + if (actors is! List) { 145 + return const []; 146 + } 147 + 148 + final results = <TypeaheadResult>[]; 149 + for (final actor in actors) { 150 + if (actor is! Map<String, dynamic>) { 151 + continue; 152 + } 153 + 154 + try { 155 + results.add(TypeaheadResult.fromJson(actor)); 156 + } catch (error, stackTrace) { 157 + log.w('TypeaheadRepository: skipped invalid Bluesky actor payload.', error: error, stackTrace: stackTrace); 158 + } 159 + } 160 + 120 161 return _applyModeration(results); 121 162 } 122 163
+1 -1
test/features/settings/presentation/search_settings_test.dart
··· 94 94 await tester.scrollUntilVisible(find.text('Typeahead Provider'), 300); 95 95 96 96 expect(find.text('Typeahead Provider'), findsOneWidget); 97 - expect(find.text('Bluesky official endpoint selected. Requires login.'), findsOneWidget); 97 + expect(find.text('Bluesky official endpoint selected.'), findsOneWidget); 98 98 expect(find.text('Bluesky'), findsOneWidget); 99 99 expect(find.text('Community'), findsOneWidget); 100 100 });
+40
test/features/typeahead/data/typeahead_repository_test.dart
··· 193 193 expect(() => repository.search(query: 'alice', limit: 5), throwsA(isA<SocketException>())); 194 194 }); 195 195 196 + test('bluesky provider uses public HTTP endpoint when SDK client is unavailable', () async { 197 + Uri? requestedUri; 198 + Map<String, String>? requestHeaders; 199 + 200 + final client = _CallbackClient((request) async { 201 + requestedUri = request.url; 202 + requestHeaders = request.headers; 203 + return http.Response( 204 + jsonEncode({ 205 + 'actors': [ 206 + {'did': 'did:plc:public', 'handle': 'public.bsky.social', 'displayName': 'Public'}, 207 + ], 208 + }), 209 + 200, 210 + ); 211 + }); 212 + 213 + final repository = TypeaheadRepository( 214 + provider: TypeaheadRepository.blueskyProvider, 215 + moderationService: moderationService, 216 + httpClient: client, 217 + ); 218 + 219 + final results = await repository.search(query: 'public', limit: 6); 220 + 221 + expect(results, hasLength(1)); 222 + expect(results.single.did, 'did:plc:public'); 223 + expect(results.single.handle, 'public.bsky.social'); 224 + expect(results.single.displayName, 'Public'); 225 + expect(requestedUri, isNotNull); 226 + expect(requestedUri!.scheme, 'https'); 227 + expect(requestedUri!.host, 'public.api.bsky.app'); 228 + expect(requestedUri!.path, '/xrpc/app.bsky.actor.searchActorsTypeahead'); 229 + expect(requestedUri!.queryParameters['q'], 'public'); 230 + expect(requestedUri!.queryParameters['limit'], '6'); 231 + expect(requestHeaders?['X-Client'], 'lazurite'); 232 + expect(requestHeaders?['x-test'], 'moderation'); 233 + expect(requestHeaders?['atproto-proxy'], isNull); 234 + }); 235 + 196 236 test('search returns empty list for empty/whitespace queries', () async { 197 237 final client = _CallbackClient((_) async => throw StateError('Should not be called')); 198 238