diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -71,6 +71,8 @@ const _ModerationSettingsPreview(), const SizedBox(height: 24), _buildSectionHeader(context, 'Account'), + const _AtProtocolConnectionCard(), + const SizedBox(height: 12), _SettingsTile( icon: Icons.dynamic_feed_outlined, title: 'Feeds', @@ -357,6 +359,52 @@ } } +class _AtProtocolConnectionCard extends StatelessWidget { + const _AtProtocolConnectionCard(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, authState) { + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null) { + return const SizedBox.shrink(); + } + + final pds = tokens.service?.trim().isNotEmpty == true ? tokens.service!.trim() : 'bsky.social'; + + 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: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text('AT Protocol Connection', style: Theme.of(context).textTheme.titleMedium), + ), + const Divider(height: 1), + _ConnectionDetailRow(label: 'Handle', value: '@${tokens.handle}'), + const Divider(height: 1), + // TODO: Link the DID row to dev_tools_screen.dart + _ConnectionDetailRow(label: 'DID', value: tokens.did), + const Divider(height: 1), + _ConnectionDetailRow(label: 'PDS', value: pds), + ], + ), + ), + ); + }, + ); + } +} + enum _AppearanceMode { system, light, @@ -432,6 +480,33 @@ onChanged: onChanged, items: [for (final option in options) DropdownMenuItem(value: option, child: Text(labelBuilder(option)))], ), + ), + ); + } +} + +class _ConnectionDetailRow extends StatelessWidget { + const _ConnectionDetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), + ], ), ); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -2,28 +2,41 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; import 'package:mocktail/mocktail.dart'; + +class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} class MockAuthBloc extends MockBloc implements AuthBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} void main() { + late MockAccountSwitcherCubit accountSwitcherCubit; late MockAuthBloc authBloc; late MockSettingsCubit settingsCubit; setUp(() { + accountSwitcherCubit = MockAccountSwitcherCubit(); authBloc = MockAuthBloc(); settingsCubit = MockSettingsCubit(); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); + whenListen( + accountSwitcherCubit, + const Stream.empty(), + initialState: const AccountSwitcherState.ready(accounts: []), + ); when(() => settingsCubit.state).thenReturn( const SettingsState( @@ -49,6 +62,7 @@ return MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), + BlocProvider.value(value: accountSwitcherCubit), BlocProvider.value(value: settingsCubit), ], child: const MaterialApp(home: SettingsScreen()), @@ -64,6 +78,54 @@ expect(find.text('LAYOUT'), findsOneWidget); expect(find.text('Feed Architecture'), findsOneWidget); expect(find.text('Thread Auto-Collapse'), findsOneWidget); + }); + + testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { + const tokens = AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + did: 'did:plc:lazurite123', + handle: 'owais.bsky.social', + service: 'https://pds.example.com', + ); + final account = Account( + did: tokens.did, + handle: tokens.handle, + displayName: 'Owais', + service: tokens.service, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, + expiresAt: null, + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), + ); + + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); + whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); + when( + () => accountSwitcherCubit.state, + ).thenReturn(AccountSwitcherState.ready(accounts: [account], activeDid: account.did)); + whenListen( + accountSwitcherCubit, + const Stream.empty(), + initialState: AccountSwitcherState.ready(accounts: [account], activeDid: account.did), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('AT Protocol Connection'), 300); + await tester.pumpAndSettle(); + + expect(find.text('AT Protocol Connection'), findsOneWidget); + expect(find.text('HANDLE'), findsOneWidget); + expect(find.text('@owais.bsky.social'), findsOneWidget); + expect(find.text('DID'), findsOneWidget); + expect(find.text('did:plc:lazurite123'), findsOneWidget); + expect(find.text('PDS'), findsOneWidget); + expect(find.text('https://pds.example.com'), findsOneWidget); }); testWidgets('does not render removed placeholder settings', (tester) async {