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: Feed Architecture -> Feed Layout

+270 -138
+4
docs/TODO.md
··· 40 40 41 41 - Render feed from cache if it goes down (> 500 error) 42 42 43 + --- 44 + 45 + - Sidebar profile link should open account switcher, not go to profile. Long press to go to profile. 46 + 43 47 ## Privacy Policy 44 48 45 49 - Should mention that Lazurite is an AppView that doesn't store any user data.
+26 -1
lib/core/database/app_database.dart
··· 25 25 static const activeAccountDidSettingKey = 'active_account_did'; 26 26 27 27 @override 28 - int get schemaVersion => 13; 28 + int get schemaVersion => 14; 29 29 30 30 @override 31 31 MigrationStrategy get migration => MigrationStrategy( ··· 75 75 } 76 76 if (from < 13) { 77 77 await customStatement("DELETE FROM settings WHERE key = 'ui_density'"); 78 + } 79 + if (from < 14) { 80 + await customStatement(''' 81 + INSERT OR IGNORE INTO settings (key, value, updated_at) 82 + SELECT 83 + 'feed_layout', 84 + CASE value 85 + WHEN 'grid' THEN 'card' 86 + WHEN 'linear' THEN 'compact' 87 + ELSE value 88 + END, 89 + updated_at 90 + FROM settings 91 + WHERE key = 'feed_architecture' 92 + '''); 93 + await customStatement(''' 94 + UPDATE settings 95 + SET value = CASE value 96 + WHEN 'grid' THEN 'card' 97 + WHEN 'linear' THEN 'compact' 98 + ELSE value 99 + END 100 + WHERE key = 'feed_layout' 101 + '''); 102 + await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'"); 78 103 } 79 104 }, 80 105 );
+52
lib/core/network/atproto_host_resolver.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' as atp_core; 2 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 3 + 4 + String resolvePdsHost(AuthTokens tokens) { 5 + final oauthHost = _resolveOAuthPdsHost(tokens); 6 + if (oauthHost != null) { 7 + return oauthHost; 8 + } 9 + 10 + final storedHost = normalizeAtprotoServiceHost(tokens.service); 11 + if (storedHost != null) { 12 + return storedHost; 13 + } 14 + 15 + return 'Unknown'; 16 + } 17 + 18 + String? normalizeAtprotoServiceHost(String? value) { 19 + final trimmed = value?.trim(); 20 + if (trimmed == null || trimmed.isEmpty) { 21 + return null; 22 + } 23 + 24 + final uri = Uri.tryParse(trimmed); 25 + if (uri != null && uri.host.isNotEmpty) { 26 + return uri.host; 27 + } 28 + 29 + return trimmed; 30 + } 31 + 32 + String? _resolveOAuthPdsHost(AuthTokens tokens) { 33 + if (!tokens.usesOAuth || 34 + tokens.refreshToken == null || 35 + tokens.dpopPublicKey == null || 36 + tokens.dpopPrivateKey == null) { 37 + return null; 38 + } 39 + 40 + try { 41 + final session = atp_core.restoreOAuthSession( 42 + accessToken: tokens.accessToken, 43 + refreshToken: tokens.refreshToken!, 44 + dPoPNonce: tokens.dpopNonce, 45 + publicKey: tokens.dpopPublicKey!, 46 + privateKey: tokens.dpopPrivateKey!, 47 + ); 48 + return normalizeAtprotoServiceHost(session.atprotoPdsEndpoint); 49 + } catch (_) { 50 + return null; 51 + } 52 + }
-13
lib/core/theme/feed_architecture.dart
··· 1 - enum FeedArchitecture { 2 - grid, 3 - linear; 4 - 5 - static FeedArchitecture fromString(String? value) { 6 - switch (value) { 7 - case 'linear': 8 - return FeedArchitecture.linear; 9 - default: 10 - return FeedArchitecture.grid; 11 - } 12 - } 13 - }
+17
lib/core/theme/feed_layout.dart
··· 1 + enum FeedLayout { 2 + card, 3 + compact; 4 + 5 + static FeedLayout fromString(String? value) { 6 + switch (value) { 7 + case 'linear': 8 + case 'compact': 9 + return FeedLayout.compact; 10 + case 'grid': 11 + case 'card': 12 + return FeedLayout.card; 13 + default: 14 + return FeedLayout.card; 15 + } 16 + } 17 + }
+3 -1
lib/features/auth/data/auth_repository.dart
··· 10 10 import 'package:http/http.dart' as http; 11 11 import 'package:lazurite/core/database/app_database.dart'; 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 13 14 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 14 15 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 15 16 import 'package:url_launcher/url_launcher.dart'; ··· 375 376 'AuthRepository: OAuth session will target PDS ' 376 377 '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', 377 378 ); 379 + final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? oauthService; 378 380 379 381 try { 380 382 final authSession = await createAtProtoForOAuthSession(session).server.getSession(); ··· 401 403 did: session.sub, 402 404 handle: resolvedHandle, 403 405 displayName: displayName, 404 - service: oauthService, 406 + service: pdsHost, 405 407 dpopNonce: session.$dPoPNonce, 406 408 dpopPublicKey: session.$publicKey, 407 409 dpopPrivateKey: session.$privateKey,
+7 -7
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 - import 'package:lazurite/core/theme/feed_architecture.dart'; 3 + import 'package:lazurite/core/theme/feed_layout.dart'; 4 4 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 5 5 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 6 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 9 9 const double _gridCardChromeHeight = 160; 10 10 11 11 /// Renders a scrollable list of items in either a responsive [SliverGrid] 12 - /// (grid architecture) or a padded [ListView] (linear architecture), driven 13 - /// by [SettingsCubit.feedArchitecture]. 12 + /// (card layout) or a padded [ListView] (compact layout), driven 13 + /// by [SettingsCubit.feedLayout]. 14 14 /// 15 - /// [gridItemBuilder] is used when the grid architecture is active. 16 - /// [linearItemBuilder] is used when the linear architecture is active. 15 + /// [gridItemBuilder] is used when the card layout is active. 16 + /// [linearItemBuilder] is used when the compact layout is active. 17 17 /// This allows the caller to render the appropriate card variant for each mode. 18 18 class FeedLayoutView extends StatelessWidget { 19 19 const FeedLayoutView({ ··· 36 36 @override 37 37 Widget build(BuildContext context) { 38 38 return BlocBuilder<SettingsCubit, SettingsState>( 39 - buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, 39 + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 40 40 builder: (context, settingsState) { 41 - if (settingsState.feedArchitecture == FeedArchitecture.grid) { 41 + if (settingsState.feedLayout == FeedLayout.card) { 42 42 return _buildGrid(context); 43 43 } 44 44 return _buildLinear(context);
+6 -6
lib/features/profile/presentation/profile_screen.dart
··· 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:intl/intl.dart'; 9 9 import 'package:lazurite/core/router/app_shell.dart'; 10 - import 'package:lazurite/core/theme/feed_architecture.dart'; 10 + import 'package:lazurite/core/theme/feed_layout.dart'; 11 11 import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; 12 12 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 13 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; ··· 19 19 import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 20 20 import 'package:lazurite/features/lists/data/list_repository.dart'; 21 21 import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 22 - import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 23 - import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 24 - import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 25 22 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 26 23 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 27 24 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; ··· 31 28 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 32 29 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 33 30 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 31 + import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 32 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 33 + import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 34 34 import 'package:share_plus/share_plus.dart'; 35 35 import 'package:url_launcher/url_launcher.dart'; 36 36 ··· 629 629 } 630 630 631 631 return BlocBuilder<SettingsCubit, SettingsState>( 632 - buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, 632 + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 633 633 builder: (context, settingsState) { 634 - if (settingsState.feedArchitecture == FeedArchitecture.grid) { 634 + if (settingsState.feedLayout == FeedLayout.card) { 635 635 return _buildGridFeed(context, feedState); 636 636 } 637 637 return _buildLinearFeed(context, feedState);
+12 -9
lib/features/settings/bloc/settings_cubit.dart
··· 1 1 import 'package:flutter_bloc/flutter_bloc.dart'; 2 2 import 'package:lazurite/core/database/app_database.dart'; 3 3 import 'package:lazurite/core/theme/app_theme.dart'; 4 - import 'package:lazurite/core/theme/feed_architecture.dart'; 4 + import 'package:lazurite/core/theme/feed_layout.dart'; 5 5 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 6 6 7 7 class SettingsCubit extends Cubit<SettingsState> { ··· 10 10 AppThemePalette? initialPalette, 11 11 AppThemeVariant? initialVariant, 12 12 bool? initialUseSystemTheme, 13 - FeedArchitecture? initialFeedArchitecture, 13 + FeedLayout? initialFeedLayout, 14 14 bool? initialSimulateOffline, 15 15 int? initialThreadAutoCollapseDepth, 16 16 }) : super( ··· 18 18 themePalette: initialPalette ?? AppThemePalette.oxocarbon, 19 19 themeVariant: initialVariant ?? AppThemeVariant.dark, 20 20 useSystemTheme: initialUseSystemTheme ?? false, 21 - feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, 21 + feedLayout: initialFeedLayout ?? FeedLayout.card, 22 22 simulateOffline: initialSimulateOffline ?? false, 23 23 threadAutoCollapseDepth: initialThreadAutoCollapseDepth, 24 24 ), ··· 29 29 static const String _keyThemePalette = 'theme_palette'; 30 30 static const String _keyThemeVariant = 'theme_variant'; 31 31 static const String _keyUseSystemTheme = 'use_system_theme'; 32 - static const String _keyFeedArchitecture = 'feed_architecture'; 32 + static const String _keyFeedLayout = 'feed_layout'; 33 + static const String _legacyKeyFeedArchitecture = 'feed_architecture'; 33 34 static const String _keySimulateOffline = 'simulate_offline'; 34 35 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 35 36 ··· 37 38 final paletteStr = await database.getSetting(_keyThemePalette); 38 39 final variantStr = await database.getSetting(_keyThemeVariant); 39 40 final useSystemStr = await database.getSetting(_keyUseSystemTheme); 40 - final feedArchStr = await database.getSetting(_keyFeedArchitecture); 41 + final feedLayoutStr = 42 + await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); 41 43 final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 42 44 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 43 45 ··· 46 48 themePalette: AppTheme.parsePalette(paletteStr), 47 49 themeVariant: AppTheme.parseVariant(variantStr), 48 50 useSystemTheme: useSystemStr == 'true', 49 - feedArchitecture: FeedArchitecture.fromString(feedArchStr), 51 + feedLayout: FeedLayout.fromString(feedLayoutStr), 50 52 simulateOffline: simulateOfflineStr == 'true', 51 53 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 52 54 ), ··· 74 76 emit(state.copyWith(useSystemTheme: value)); 75 77 } 76 78 77 - Future<void> setFeedArchitecture(FeedArchitecture architecture) async { 78 - await database.setSetting(_keyFeedArchitecture, architecture.name); 79 - emit(state.copyWith(feedArchitecture: architecture)); 79 + Future<void> setFeedLayout(FeedLayout layout) async { 80 + await database.setSetting(_keyFeedLayout, layout.name); 81 + await database.deleteSetting(_legacyKeyFeedArchitecture); 82 + emit(state.copyWith(feedLayout: layout)); 80 83 } 81 84 82 85 Future<void> setSimulateOffline(bool value) async {
+6 -6
lib/features/settings/bloc/settings_state.dart
··· 1 1 import 'package:equatable/equatable.dart'; 2 2 import 'package:lazurite/core/theme/app_theme.dart'; 3 - import 'package:lazurite/core/theme/feed_architecture.dart'; 3 + import 'package:lazurite/core/theme/feed_layout.dart'; 4 4 5 5 const Object _threadAutoCollapseDepthUnset = Object(); 6 6 ··· 9 9 required this.themePalette, 10 10 required this.themeVariant, 11 11 required this.useSystemTheme, 12 - this.feedArchitecture = FeedArchitecture.grid, 12 + this.feedLayout = FeedLayout.card, 13 13 this.simulateOffline = false, 14 14 this.threadAutoCollapseDepth, 15 15 }); ··· 17 17 final AppThemePalette themePalette; 18 18 final AppThemeVariant themeVariant; 19 19 final bool useSystemTheme; 20 - final FeedArchitecture feedArchitecture; 20 + final FeedLayout feedLayout; 21 21 final bool simulateOffline; 22 22 final int? threadAutoCollapseDepth; 23 23 ··· 25 25 AppThemePalette? themePalette, 26 26 AppThemeVariant? themeVariant, 27 27 bool? useSystemTheme, 28 - FeedArchitecture? feedArchitecture, 28 + FeedLayout? feedLayout, 29 29 bool? simulateOffline, 30 30 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 31 31 }) { ··· 33 33 themePalette: themePalette ?? this.themePalette, 34 34 themeVariant: themeVariant ?? this.themeVariant, 35 35 useSystemTheme: useSystemTheme ?? this.useSystemTheme, 36 - feedArchitecture: feedArchitecture ?? this.feedArchitecture, 36 + feedLayout: feedLayout ?? this.feedLayout, 37 37 simulateOffline: simulateOffline ?? this.simulateOffline, 38 38 threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) 39 39 ? this.threadAutoCollapseDepth ··· 46 46 themePalette, 47 47 themeVariant, 48 48 useSystemTheme, 49 - feedArchitecture, 49 + feedLayout, 50 50 simulateOffline, 51 51 threadAutoCollapseDepth, 52 52 ];
+11 -10
lib/features/settings/presentation/settings_screen.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 5 6 import 'package:lazurite/core/router/app_shell.dart'; 6 7 import 'package:lazurite/core/theme/app_theme.dart'; 7 - import 'package:lazurite/core/theme/feed_architecture.dart'; 8 + import 'package:lazurite/core/theme/feed_layout.dart'; 8 9 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 9 10 import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 10 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; ··· 227 228 ), 228 229 child: Column( 229 230 children: [ 230 - _SettingsDropdownTile<FeedArchitecture>( 231 - title: 'Feed Architecture', 232 - value: state.feedArchitecture, 233 - options: FeedArchitecture.values, 234 - labelBuilder: (architecture) => switch (architecture) { 235 - FeedArchitecture.grid => 'Grid', 236 - FeedArchitecture.linear => 'Linear', 231 + _SettingsDropdownTile<FeedLayout>( 232 + title: 'Feed Layout', 233 + value: state.feedLayout, 234 + options: FeedLayout.values, 235 + labelBuilder: (layout) => switch (layout) { 236 + FeedLayout.card => 'Card', 237 + FeedLayout.compact => 'Compact', 237 238 }, 238 239 onChanged: (value) { 239 240 if (value != null) { 240 - settingsCubit.setFeedArchitecture(value); 241 + settingsCubit.setFeedLayout(value); 241 242 } 242 243 }, 243 244 ), ··· 371 372 return const SizedBox.shrink(); 372 373 } 373 374 374 - final pds = tokens.service?.trim().isNotEmpty == true ? tokens.service!.trim() : 'bsky.social'; 375 + final pds = resolvePdsHost(tokens); 375 376 376 377 return Container( 377 378 decoration: BoxDecoration(
+8 -8
test/core/theme/feed_architecture_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 - import 'package:lazurite/core/theme/feed_architecture.dart'; 2 + import 'package:lazurite/core/theme/feed_layout.dart'; 3 3 4 4 void main() { 5 - group('FeedArchitecture', () { 5 + group('FeedLayout', () { 6 6 group('fromString', () { 7 7 test('parses grid', () { 8 - expect(FeedArchitecture.fromString('grid'), FeedArchitecture.grid); 8 + expect(FeedLayout.fromString('grid'), FeedLayout.card); 9 9 }); 10 10 11 11 test('parses linear', () { 12 - expect(FeedArchitecture.fromString('linear'), FeedArchitecture.linear); 12 + expect(FeedLayout.fromString('linear'), FeedLayout.compact); 13 13 }); 14 14 15 15 test('null returns grid', () { 16 - expect(FeedArchitecture.fromString(null), FeedArchitecture.grid); 16 + expect(FeedLayout.fromString(null), FeedLayout.card); 17 17 }); 18 18 19 19 test('unknown value returns grid', () { 20 - expect(FeedArchitecture.fromString('unknown'), FeedArchitecture.grid); 20 + expect(FeedLayout.fromString('unknown'), FeedLayout.card); 21 21 }); 22 22 23 23 test('round-trips all values via name', () { 24 - for (final arch in FeedArchitecture.values) { 25 - expect(FeedArchitecture.fromString(arch.name), arch, reason: 'arch: $arch'); 24 + for (final arch in FeedLayout.values) { 25 + expect(FeedLayout.fromString(arch.name), arch, reason: 'arch: $arch'); 26 26 } 27 27 }); 28 28 });
+21 -21
test/features/feed/presentation/home_feed_screen_test.dart
··· 1 1 import 'dart:async'; 2 2 3 - import 'package:bluesky/app_bsky_actor_defs.dart'; 4 3 import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 8 import 'package:lazurite/core/theme/app_theme.dart'; 9 - import 'package:lazurite/core/theme/feed_architecture.dart'; 9 + import 'package:lazurite/core/theme/feed_layout.dart'; 10 10 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 11 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 12 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 28 28 29 29 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 30 30 31 - SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( 31 + SettingsState _settingsState(FeedLayout architecture) => SettingsState( 32 32 themePalette: AppThemePalette.oxocarbon, 33 33 themeVariant: AppThemeVariant.dark, 34 34 useSystemTheme: false, 35 - feedArchitecture: architecture, 35 + feedLayout: architecture, 36 36 ); 37 37 38 38 const _homeFeedState = FeedPreferencesState.loaded( ··· 46 46 ], 47 47 ); 48 48 49 - Widget _buildSubject({required FeedArchitecture architecture, double screenWidth = 400, int itemCount = 3}) { 49 + Widget _buildSubject({required FeedLayout architecture, double screenWidth = 400, int itemCount = 3}) { 50 50 final cubit = MockSettingsCubit(); 51 51 when(() => cubit.state).thenReturn(_settingsState(architecture)); 52 52 ··· 133 133 134 134 group('FeedLayoutView — grid architecture', () { 135 135 testWidgets('shows SliverGrid when architecture is grid', (tester) async { 136 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 136 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 137 137 expect(find.byType(SliverGrid), findsOneWidget); 138 138 expect(find.byType(CustomScrollView), findsOneWidget); 139 139 }); 140 140 141 141 testWidgets('uses gridItemBuilder in grid mode', (tester) async { 142 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); 142 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); 143 143 expect(find.text('grid 0'), findsOneWidget); 144 144 expect(find.text('linear 0'), findsNothing); 145 145 }); 146 146 147 147 testWidgets('uses 1 column at width < 600', (tester) async { 148 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); 148 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); 149 149 150 150 expect(find.byType(SliverGrid), findsNothing); 151 151 expect(find.byType(SliverList), findsOneWidget); 152 152 }); 153 153 154 154 testWidgets('uses tighter single-column padding at phone widths', (tester) async { 155 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); 155 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); 156 156 157 157 final padding = tester.widget<SliverPadding>(find.byType(SliverPadding)); 158 158 expect(padding.padding, const EdgeInsets.fromLTRB(12, 8, 12, 12)); 159 159 }); 160 160 161 161 testWidgets('uses 2 columns at width 600–839', (tester) async { 162 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 162 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 163 163 164 164 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 165 165 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; ··· 167 167 }); 168 168 169 169 testWidgets('uses 3 columns at width 840–1199', (tester) async { 170 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1000)); 170 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1000)); 171 171 172 172 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 173 173 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; ··· 175 175 }); 176 176 177 177 testWidgets('uses 4 columns at width >= 1200', (tester) async { 178 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1400)); 178 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1400)); 179 179 180 180 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 181 181 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; ··· 183 183 }); 184 184 185 185 testWidgets('allocates extra height beyond the square media region', (tester) async { 186 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 186 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 187 187 188 188 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 189 189 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; ··· 196 196 197 197 group('FeedLayoutView — linear architecture', () { 198 198 testWidgets('shows ListView when architecture is linear', (tester) async { 199 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 199 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 200 200 201 201 expect(find.byType(ListView), findsOneWidget); 202 202 expect(find.byType(SliverGrid), findsNothing); 203 203 }); 204 204 205 205 testWidgets('uses linearItemBuilder in linear mode', (tester) async { 206 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 206 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 207 207 208 208 expect(find.text('linear 0'), findsOneWidget); 209 209 expect(find.text('linear 1'), findsOneWidget); ··· 211 211 }); 212 212 213 213 testWidgets('uses tighter vertical spacing in linear mode', (tester) async { 214 - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 214 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 215 215 216 216 final listView = tester.widget<ListView>(find.byType(ListView)); 217 217 expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); ··· 223 223 final cubit = MockSettingsCubit(); 224 224 final streamController = StreamController<SettingsState>.broadcast(); 225 225 226 - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); 226 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 227 227 when(() => cubit.stream).thenAnswer((_) => streamController.stream); 228 228 229 229 var buildCount = 0; ··· 251 251 252 252 expect(find.byType(SliverGrid), findsOneWidget); 253 253 254 - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); 255 - streamController.add(_settingsState(FeedArchitecture.linear)); 254 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 255 + streamController.add(_settingsState(FeedLayout.compact)); 256 256 await tester.pump(); 257 257 258 258 expect(find.byType(SliverGrid), findsNothing); ··· 264 264 265 265 testWidgets('loading indicator appears when isLoadingMore is true in grid mode', (tester) async { 266 266 final cubit = MockSettingsCubit(); 267 - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); 267 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 268 268 269 269 await tester.pumpWidget( 270 270 MediaQuery( ··· 292 292 293 293 testWidgets('loading indicator appears when isLoadingMore is true in linear mode', (tester) async { 294 294 final cubit = MockSettingsCubit(); 295 - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); 295 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 296 296 297 297 await tester.pumpWidget( 298 298 MediaQuery(
+12 -12
test/features/profile/presentation/profile_screen_test.dart
··· 10 10 import 'package:flutter_test/flutter_test.dart'; 11 11 import 'package:go_router/go_router.dart'; 12 12 import 'package:lazurite/core/theme/app_theme.dart'; 13 - import 'package:lazurite/core/theme/feed_architecture.dart'; 13 + import 'package:lazurite/core/theme/feed_layout.dart'; 14 14 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 15 15 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 16 16 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; ··· 19 19 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 20 20 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 21 21 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 22 - import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 23 22 import 'package:lazurite/features/lists/data/list_repository.dart'; 23 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 24 24 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 25 25 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 26 26 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; ··· 78 78 themePalette: AppThemePalette.oxocarbon, 79 79 themeVariant: AppThemeVariant.dark, 80 80 useSystemTheme: false, 81 - feedArchitecture: FeedArchitecture.grid, 81 + feedLayout: FeedLayout.card, 82 82 ); 83 83 84 - SettingsState settingsStateWith(FeedArchitecture architecture) => SettingsState( 84 + SettingsState settingsStateWith(FeedLayout architecture) => SettingsState( 85 85 themePalette: AppThemePalette.oxocarbon, 86 86 themeVariant: AppThemeVariant.dark, 87 87 useSystemTheme: false, 88 - feedArchitecture: architecture, 88 + feedLayout: architecture, 89 89 ); 90 90 91 91 setUp(() { ··· 427 427 428 428 testWidgets('grid mode shows centered large grid cards without the metadata info card', (tester) async { 429 429 final cubit = MockSettingsCubit(); 430 - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); 431 - whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedArchitecture.grid)); 430 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 431 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.card)); 432 432 433 433 await tester.pumpWidget(buildWithPosts(tester, cubit)); 434 434 await tester.pump(); ··· 442 442 443 443 testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { 444 444 final cubit = MockSettingsCubit(); 445 - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); 446 - whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedArchitecture.linear)); 445 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 446 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 447 447 448 448 await tester.pumpWidget(buildWithPosts(tester, cubit)); 449 449 await tester.pump(); ··· 457 457 final cubit = MockSettingsCubit(); 458 458 final streamCtrl = StreamController<SettingsState>.broadcast(); 459 459 460 - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); 460 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 461 461 when(() => cubit.stream).thenAnswer((_) => streamCtrl.stream); 462 462 463 463 await tester.pumpWidget(buildWithPosts(tester, cubit)); ··· 466 466 expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 467 467 expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 468 468 469 - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); 470 - streamCtrl.add(settingsStateWith(FeedArchitecture.linear)); 469 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 470 + streamCtrl.add(settingsStateWith(FeedLayout.compact)); 471 471 await tester.pumpAndSettle(); 472 472 473 473 expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing);
+34 -22
test/features/settings/bloc/settings_cubit_test.dart
··· 3 3 import 'package:flutter_test/flutter_test.dart'; 4 4 import 'package:lazurite/core/database/app_database.dart'; 5 5 import 'package:lazurite/core/theme/app_theme.dart'; 6 - import 'package:lazurite/core/theme/feed_architecture.dart'; 6 + import 'package:lazurite/core/theme/feed_layout.dart'; 7 7 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 8 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 9 9 ··· 24 24 expect(cubit.state.themePalette, AppThemePalette.oxocarbon); 25 25 expect(cubit.state.themeVariant, AppThemeVariant.dark); 26 26 expect(cubit.state.useSystemTheme, false); 27 - expect(cubit.state.feedArchitecture, FeedArchitecture.grid); 27 + expect(cubit.state.feedLayout, FeedLayout.card); 28 28 expect(cubit.state.simulateOffline, false); 29 29 expect(cubit.state.threadAutoCollapseDepth, isNull); 30 30 }); ··· 35 35 initialPalette: AppThemePalette.catppuccin, 36 36 initialVariant: AppThemeVariant.light, 37 37 initialUseSystemTheme: true, 38 - initialFeedArchitecture: FeedArchitecture.linear, 38 + initialFeedLayout: FeedLayout.compact, 39 39 initialSimulateOffline: true, 40 40 initialThreadAutoCollapseDepth: 3, 41 41 ); 42 42 expect(cubit.state.themePalette, AppThemePalette.catppuccin); 43 43 expect(cubit.state.themeVariant, AppThemeVariant.light); 44 44 expect(cubit.state.useSystemTheme, true); 45 - expect(cubit.state.feedArchitecture, FeedArchitecture.linear); 45 + expect(cubit.state.feedLayout, FeedLayout.compact); 46 46 expect(cubit.state.simulateOffline, true); 47 47 expect(cubit.state.threadAutoCollapseDepth, 3); 48 48 }); ··· 64 64 .having((s) => s.themePalette, 'themePalette', AppThemePalette.nord) 65 65 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) 66 66 .having((s) => s.useSystemTheme, 'useSystemTheme', true) 67 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) 67 + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) 68 68 .having((s) => s.simulateOffline, 'simulateOffline', true) 69 69 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), 70 70 ], ··· 79 79 .having((s) => s.themePalette, 'themePalette', AppThemePalette.oxocarbon) 80 80 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) 81 81 .having((s) => s.useSystemTheme, 'useSystemTheme', false) 82 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) 82 + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) 83 83 .having((s) => s.simulateOffline, 'simulateOffline', false) 84 84 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 85 85 ], ··· 134 134 ); 135 135 136 136 blocTest<SettingsCubit, SettingsState>( 137 - 'setFeedArchitecture updates state and persists to database', 137 + 'setFeedLayout updates state and persists to database', 138 138 build: () => SettingsCubit(database: database), 139 - act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.linear), 140 - expect: () => [ 141 - isA<SettingsState>().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 142 - ], 139 + act: (cubit) => cubit.setFeedLayout(FeedLayout.compact), 140 + expect: () => [isA<SettingsState>().having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact)], 143 141 verify: (cubit) async { 144 - final value = await database.getSetting('feed_architecture'); 145 - expect(value, 'linear'); 142 + final value = await database.getSetting('feed_layout'); 143 + expect(value, 'compact'); 146 144 }, 147 145 ); 148 146 149 147 blocTest<SettingsCubit, SettingsState>( 150 - 'setFeedArchitecture grid updates state and persists to database', 151 - build: () => SettingsCubit(database: database, initialFeedArchitecture: FeedArchitecture.linear), 152 - act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.grid), 153 - expect: () => [isA<SettingsState>().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid)], 148 + 'setFeedLayout card updates state and persists to database', 149 + build: () => SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact), 150 + act: (cubit) => cubit.setFeedLayout(FeedLayout.card), 151 + expect: () => [isA<SettingsState>().having((s) => s.feedLayout, 'feedLayout', FeedLayout.card)], 154 152 verify: (cubit) async { 155 - final value = await database.getSetting('feed_architecture'); 156 - expect(value, 'grid'); 153 + final value = await database.getSetting('feed_layout'); 154 + expect(value, 'card'); 155 + }, 156 + ); 157 + 158 + blocTest<SettingsCubit, SettingsState>( 159 + 'setFeedLayout clears the legacy feed_architecture setting', 160 + build: () => SettingsCubit(database: database), 161 + setUp: () async { 162 + await database.setSetting('feed_architecture', 'linear'); 163 + }, 164 + act: (cubit) => cubit.setFeedLayout(FeedLayout.compact), 165 + expect: () => [isA<SettingsState>().having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact)], 166 + verify: (cubit) async { 167 + expect(await database.getSetting('feed_layout'), 'compact'); 168 + expect(await database.getSetting('feed_architecture'), isNull); 157 169 }, 158 170 ); 159 171 ··· 194 206 ); 195 207 196 208 blocTest<SettingsCubit, SettingsState>( 197 - 'loadSettings round-trips feed_architecture and thread auto-collapse depth', 209 + 'loadSettings round-trips feed_layout and thread auto-collapse depth', 198 210 build: () => SettingsCubit(database: database), 199 211 setUp: () async { 200 - await database.setSetting('feed_architecture', 'linear'); 212 + await database.setSetting('feed_layout', 'linear'); 201 213 await database.setSetting('thread_auto_collapse_depth', '6'); 202 214 }, 203 215 act: (cubit) => cubit.loadSettings(), 204 216 expect: () => [ 205 217 isA<SettingsState>() 206 - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) 218 + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) 207 219 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), 208 220 ], 209 221 );
+12 -12
test/features/settings/bloc/settings_state_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 2 import 'package:lazurite/core/theme/app_theme.dart'; 3 - import 'package:lazurite/core/theme/feed_architecture.dart'; 3 + import 'package:lazurite/core/theme/feed_layout.dart'; 4 4 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 5 5 6 6 void main() { ··· 65 65 expect(state1, isNot(equals(state2))); 66 66 }); 67 67 68 - test('inequality when feedArchitecture differs', () { 68 + test('inequality when feedLayout differs', () { 69 69 const state1 = SettingsState( 70 70 themePalette: AppThemePalette.oxocarbon, 71 71 themeVariant: AppThemeVariant.dark, 72 72 useSystemTheme: false, 73 - feedArchitecture: FeedArchitecture.grid, 73 + feedLayout: FeedLayout.card, 74 74 ); 75 75 const state2 = SettingsState( 76 76 themePalette: AppThemePalette.oxocarbon, 77 77 themeVariant: AppThemeVariant.dark, 78 78 useSystemTheme: false, 79 - feedArchitecture: FeedArchitecture.linear, 79 + feedLayout: FeedLayout.compact, 80 80 ); 81 81 82 82 expect(state1, isNot(equals(state2))); ··· 127 127 themePalette: AppThemePalette.nord, 128 128 themeVariant: AppThemeVariant.light, 129 129 useSystemTheme: true, 130 - feedArchitecture: FeedArchitecture.linear, 130 + feedLayout: FeedLayout.compact, 131 131 simulateOffline: true, 132 132 threadAutoCollapseDepth: 3, 133 133 ); ··· 135 135 expect(updated.themePalette, AppThemePalette.nord); 136 136 expect(updated.themeVariant, AppThemeVariant.light); 137 137 expect(updated.useSystemTheme, true); 138 - expect(updated.feedArchitecture, FeedArchitecture.linear); 138 + expect(updated.feedLayout, FeedLayout.compact); 139 139 expect(updated.simulateOffline, true); 140 140 expect(updated.threadAutoCollapseDepth, 3); 141 141 expect(original.themePalette, AppThemePalette.oxocarbon); ··· 146 146 themePalette: AppThemePalette.catppuccin, 147 147 themeVariant: AppThemeVariant.light, 148 148 useSystemTheme: true, 149 - feedArchitecture: FeedArchitecture.linear, 149 + feedLayout: FeedLayout.compact, 150 150 simulateOffline: true, 151 151 threadAutoCollapseDepth: 4, 152 152 ); ··· 156 156 expect(updated.themePalette, AppThemePalette.catppuccin); 157 157 expect(updated.themeVariant, AppThemeVariant.light); 158 158 expect(updated.useSystemTheme, true); 159 - expect(updated.feedArchitecture, FeedArchitecture.linear); 159 + expect(updated.feedLayout, FeedLayout.compact); 160 160 expect(updated.simulateOffline, true); 161 161 expect(updated.threadAutoCollapseDepth, 4); 162 162 }); ··· 179 179 themePalette: AppThemePalette.rosePine, 180 180 themeVariant: AppThemeVariant.light, 181 181 useSystemTheme: true, 182 - feedArchitecture: FeedArchitecture.linear, 182 + feedLayout: FeedLayout.compact, 183 183 simulateOffline: true, 184 184 threadAutoCollapseDepth: 6, 185 185 ); ··· 187 187 expect(state.props, contains(AppThemePalette.rosePine)); 188 188 expect(state.props, contains(AppThemeVariant.light)); 189 189 expect(state.props, contains(true)); 190 - expect(state.props, contains(FeedArchitecture.linear)); 190 + expect(state.props, contains(FeedLayout.compact)); 191 191 expect(state.props, contains(true)); 192 192 expect(state.props, contains(6)); 193 193 }); 194 194 195 - test('defaults feedArchitecture to grid', () { 195 + test('defaults feedLayout to card', () { 196 196 const state = SettingsState( 197 197 themePalette: AppThemePalette.oxocarbon, 198 198 themeVariant: AppThemeVariant.dark, 199 199 useSystemTheme: false, 200 200 ); 201 - expect(state.feedArchitecture, FeedArchitecture.grid); 201 + expect(state.feedLayout, FeedLayout.card); 202 202 }); 203 203 204 204 test('defaults simulateOffline to false', () {
+39 -10
test/features/settings/presentation/settings_screen_test.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; 4 6 import 'package:flutter_test/flutter_test.dart'; 5 7 import 'package:lazurite/core/database/app_database.dart'; 6 8 import 'package:lazurite/core/theme/app_theme.dart'; 7 - import 'package:lazurite/core/theme/feed_architecture.dart'; 9 + import 'package:lazurite/core/theme/feed_layout.dart'; 8 10 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 9 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 10 12 import 'package:lazurite/features/auth/data/models/auth_models.dart'; ··· 43 45 themePalette: AppThemePalette.oxocarbon, 44 46 themeVariant: AppThemeVariant.dark, 45 47 useSystemTheme: false, 46 - feedArchitecture: FeedArchitecture.grid, 48 + feedLayout: FeedLayout.card, 47 49 ), 48 50 ); 49 51 whenListen( ··· 53 55 themePalette: AppThemePalette.oxocarbon, 54 56 themeVariant: AppThemeVariant.dark, 55 57 useSystemTheme: false, 56 - feedArchitecture: FeedArchitecture.grid, 58 + feedLayout: FeedLayout.card, 57 59 ), 58 60 ); 59 61 }); ··· 76 78 expect(find.text('APPEARANCE'), findsOneWidget); 77 79 expect(find.text('System'), findsOneWidget); 78 80 expect(find.text('LAYOUT'), findsOneWidget); 79 - expect(find.text('Feed Architecture'), findsOneWidget); 81 + expect(find.text('Feed Layout'), findsOneWidget); 80 82 expect(find.text('Thread Auto-Collapse'), findsOneWidget); 81 83 }); 82 84 83 85 testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { 84 - const tokens = AuthTokens( 85 - accessToken: 'access-token', 86 + final tokens = AuthTokens( 87 + accessToken: _buildJwt( 88 + aud: 'shaggymane.us-west.host.bsky.network', 89 + sub: 'did:plc:lazurite123', 90 + clientId: 'https://client.example/metadata.json', 91 + iss: 'https://bsky.social', 92 + ), 86 93 refreshToken: 'refresh-token', 87 94 did: 'did:plc:lazurite123', 88 95 handle: 'owais.bsky.social', 89 - service: 'https://pds.example.com', 96 + service: 'bsky.social', 97 + dpopPublicKey: 'public-key', 98 + dpopPrivateKey: 'private-key', 99 + authMethod: AuthMethod.oauth, 90 100 ); 91 101 final account = Account( 92 102 did: tokens.did, ··· 103 113 updatedAt: DateTime.utc(2026, 1, 1), 104 114 ); 105 115 106 - when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 107 - whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 116 + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); 117 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: AuthState.authenticated(tokens)); 108 118 when( 109 119 () => accountSwitcherCubit.state, 110 120 ).thenReturn(AccountSwitcherState.ready(accounts: [account], activeDid: account.did)); ··· 125 135 expect(find.text('DID'), findsOneWidget); 126 136 expect(find.text('did:plc:lazurite123'), findsOneWidget); 127 137 expect(find.text('PDS'), findsOneWidget); 128 - expect(find.text('https://pds.example.com'), findsOneWidget); 138 + expect(find.text('shaggymane.us-west.host.bsky.network'), findsOneWidget); 129 139 }); 130 140 131 141 testWidgets('does not render removed placeholder settings', (tester) async { ··· 140 150 expect(find.text('Help & Support'), findsNothing); 141 151 }); 142 152 } 153 + 154 + String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { 155 + final header = _base64UrlEncode({'alg': 'none', 'typ': 'JWT'}); 156 + final payload = _base64UrlEncode({ 157 + 'aud': aud, 158 + 'sub': sub, 159 + 'client_id': clientId, 160 + 'scope': 'atproto transition:generic', 161 + 'iss': iss, 162 + 'exp': DateTime.now().toUtc().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, 163 + 'iat': DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000, 164 + }); 165 + 166 + return '$header.$payload.signature'; 167 + } 168 + 169 + String _base64UrlEncode(Map<String, Object> value) { 170 + return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); 171 + }