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: atproto connection

+137
+75
lib/features/settings/presentation/settings_screen.dart
··· 71 71 const _ModerationSettingsPreview(), 72 72 const SizedBox(height: 24), 73 73 _buildSectionHeader(context, 'Account'), 74 + const _AtProtocolConnectionCard(), 75 + const SizedBox(height: 12), 74 76 _SettingsTile( 75 77 icon: Icons.dynamic_feed_outlined, 76 78 title: 'Feeds', ··· 357 359 } 358 360 } 359 361 362 + class _AtProtocolConnectionCard extends StatelessWidget { 363 + const _AtProtocolConnectionCard(); 364 + 365 + @override 366 + Widget build(BuildContext context) { 367 + return BlocBuilder<AuthBloc, AuthState>( 368 + builder: (context, authState) { 369 + final tokens = authState.tokens; 370 + if (!authState.isAuthenticated || tokens == null) { 371 + return const SizedBox.shrink(); 372 + } 373 + 374 + final pds = tokens.service?.trim().isNotEmpty == true ? tokens.service!.trim() : 'bsky.social'; 375 + 376 + return Container( 377 + decoration: BoxDecoration( 378 + border: Border( 379 + top: BorderSide(color: Theme.of(context).dividerColor), 380 + bottom: BorderSide(color: Theme.of(context).dividerColor), 381 + ), 382 + color: Theme.of(context).cardColor, 383 + ), 384 + child: SelectionArea( 385 + child: Column( 386 + crossAxisAlignment: CrossAxisAlignment.start, 387 + children: [ 388 + Padding( 389 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), 390 + child: Text('AT Protocol Connection', style: Theme.of(context).textTheme.titleMedium), 391 + ), 392 + const Divider(height: 1), 393 + _ConnectionDetailRow(label: 'Handle', value: '@${tokens.handle}'), 394 + const Divider(height: 1), 395 + // TODO: Link the DID row to dev_tools_screen.dart 396 + _ConnectionDetailRow(label: 'DID', value: tokens.did), 397 + const Divider(height: 1), 398 + _ConnectionDetailRow(label: 'PDS', value: pds), 399 + ], 400 + ), 401 + ), 402 + ); 403 + }, 404 + ); 405 + } 406 + } 407 + 360 408 enum _AppearanceMode { 361 409 system, 362 410 light, ··· 432 480 onChanged: onChanged, 433 481 items: [for (final option in options) DropdownMenuItem<T>(value: option, child: Text(labelBuilder(option)))], 434 482 ), 483 + ), 484 + ); 485 + } 486 + } 487 + 488 + class _ConnectionDetailRow extends StatelessWidget { 489 + const _ConnectionDetailRow({required this.label, required this.value}); 490 + 491 + final String label; 492 + final String value; 493 + 494 + @override 495 + Widget build(BuildContext context) { 496 + final theme = Theme.of(context); 497 + 498 + return Padding( 499 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), 500 + child: Column( 501 + crossAxisAlignment: CrossAxisAlignment.start, 502 + children: [ 503 + Text( 504 + label.toUpperCase(), 505 + style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 506 + ), 507 + const SizedBox(height: 4), 508 + Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), 509 + ], 435 510 ), 436 511 ); 437 512 }
+62
test/features/settings/presentation/settings_screen_test.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/database/app_database.dart'; 5 6 import 'package:lazurite/core/theme/app_theme.dart'; 6 7 import 'package:lazurite/core/theme/feed_architecture.dart'; 8 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 7 9 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 10 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 11 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 9 12 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 10 13 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 11 14 import 'package:mocktail/mocktail.dart'; 12 15 16 + class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 17 + 13 18 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 14 19 15 20 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 16 21 17 22 void main() { 23 + late MockAccountSwitcherCubit accountSwitcherCubit; 18 24 late MockAuthBloc authBloc; 19 25 late MockSettingsCubit settingsCubit; 20 26 21 27 setUp(() { 28 + accountSwitcherCubit = MockAccountSwitcherCubit(); 22 29 authBloc = MockAuthBloc(); 23 30 settingsCubit = MockSettingsCubit(); 24 31 25 32 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 26 33 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 34 + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 35 + whenListen( 36 + accountSwitcherCubit, 37 + const Stream<AccountSwitcherState>.empty(), 38 + initialState: const AccountSwitcherState.ready(accounts: []), 39 + ); 27 40 28 41 when(() => settingsCubit.state).thenReturn( 29 42 const SettingsState( ··· 49 62 return MultiBlocProvider( 50 63 providers: [ 51 64 BlocProvider<AuthBloc>.value(value: authBloc), 65 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 52 66 BlocProvider<SettingsCubit>.value(value: settingsCubit), 53 67 ], 54 68 child: const MaterialApp(home: SettingsScreen()), ··· 64 78 expect(find.text('LAYOUT'), findsOneWidget); 65 79 expect(find.text('Feed Architecture'), findsOneWidget); 66 80 expect(find.text('Thread Auto-Collapse'), findsOneWidget); 81 + }); 82 + 83 + testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { 84 + const tokens = AuthTokens( 85 + accessToken: 'access-token', 86 + refreshToken: 'refresh-token', 87 + did: 'did:plc:lazurite123', 88 + handle: 'owais.bsky.social', 89 + service: 'https://pds.example.com', 90 + ); 91 + final account = Account( 92 + did: tokens.did, 93 + handle: tokens.handle, 94 + displayName: 'Owais', 95 + service: tokens.service, 96 + accessToken: tokens.accessToken, 97 + refreshToken: tokens.refreshToken, 98 + dpopPublicKey: null, 99 + dpopPrivateKey: null, 100 + dpopNonce: null, 101 + expiresAt: null, 102 + createdAt: DateTime.utc(2026, 1, 1), 103 + updatedAt: DateTime.utc(2026, 1, 1), 104 + ); 105 + 106 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 107 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 108 + when( 109 + () => accountSwitcherCubit.state, 110 + ).thenReturn(AccountSwitcherState.ready(accounts: [account], activeDid: account.did)); 111 + whenListen( 112 + accountSwitcherCubit, 113 + const Stream<AccountSwitcherState>.empty(), 114 + initialState: AccountSwitcherState.ready(accounts: [account], activeDid: account.did), 115 + ); 116 + 117 + await tester.pumpWidget(buildSubject()); 118 + await tester.pumpAndSettle(); 119 + await tester.scrollUntilVisible(find.text('AT Protocol Connection'), 300); 120 + await tester.pumpAndSettle(); 121 + 122 + expect(find.text('AT Protocol Connection'), findsOneWidget); 123 + expect(find.text('HANDLE'), findsOneWidget); 124 + expect(find.text('@owais.bsky.social'), findsOneWidget); 125 + expect(find.text('DID'), findsOneWidget); 126 + expect(find.text('did:plc:lazurite123'), findsOneWidget); 127 + expect(find.text('PDS'), findsOneWidget); 128 + expect(find.text('https://pds.example.com'), findsOneWidget); 67 129 }); 68 130 69 131 testWidgets('does not render removed placeholder settings', (tester) async {