From 79cebfc459f61a937995b0ec6ec0d2ece6ec0b18 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 27 Mar 2026 22:14:20 +0000 Subject: [PATCH] refactor: Feed Architecture -> Feed Layout --- docs/TODO.md | 4 ++++ lib/core/database/app_database.dart | 27 ++++++++++++++++++++++++++- lib/core/network/atproto_host_resolver.dart | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/core/theme/feed_architecture.dart | 13 ------------- lib/core/theme/feed_layout.dart | 17 +++++++++++++++++ test/core/theme/feed_architecture_test.dart | 16 ++++++++-------- lib/features/auth/data/auth_repository.dart | 4 +++- lib/features/profile/presentation/profile_screen.dart | 12 ++++++------ lib/features/settings/bloc/settings_cubit.dart | 21 ++++++++++++--------- lib/features/settings/bloc/settings_state.dart | 12 ++++++------ lib/features/settings/presentation/settings_screen.dart | 21 +++++++++++---------- test/features/feed/presentation/home_feed_screen_test.dart | 42 +++++++++++++++++++++--------------------- test/features/profile/presentation/profile_screen_test.dart | 24 ++++++++++++------------ test/features/settings/bloc/settings_cubit_test.dart | 56 ++++++++++++++++++++++++++++++++++---------------------- test/features/settings/bloc/settings_state_test.dart | 24 ++++++++++++------------ test/features/settings/presentation/settings_screen_test.dart | 49 +++++++++++++++++++++++++++++++++++++++---------- lib/features/feed/presentation/widgets/feed_layout_view.dart | 14 +++++++------- 17 file(s) changed, 270 insertion(s)(+), 138 deletion(s)(-) diff --git a/docs/TODO.md b/docs/TODO.md --- a/docs/TODO.md +++ b/docs/TODO.md @@ -40,6 +40,10 @@ - Render feed from cache if it goes down (> 500 error) +--- + +- Sidebar profile link should open account switcher, not go to profile. Long press to go to profile. + ## Privacy Policy - Should mention that Lazurite is an AppView that doesn't store any user data. diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -25,7 +25,7 @@ static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 13; + int get schemaVersion => 14; @override MigrationStrategy get migration => MigrationStrategy( @@ -75,6 +75,31 @@ } if (from < 13) { await customStatement("DELETE FROM settings WHERE key = 'ui_density'"); + } + if (from < 14) { + await customStatement(''' + INSERT OR IGNORE INTO settings (key, value, updated_at) + SELECT + 'feed_layout', + CASE value + WHEN 'grid' THEN 'card' + WHEN 'linear' THEN 'compact' + ELSE value + END, + updated_at + FROM settings + WHERE key = 'feed_architecture' + '''); + await customStatement(''' + UPDATE settings + SET value = CASE value + WHEN 'grid' THEN 'card' + WHEN 'linear' THEN 'compact' + ELSE value + END + WHERE key = 'feed_layout' + '''); + await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'"); } }, ); diff --git a/lib/core/network/atproto_host_resolver.dart b/lib/core/network/atproto_host_resolver.dart new file mode 100644 --- /dev/null +++ b/lib/core/network/atproto_host_resolver.dart @@ -0,0 +1,52 @@ +import 'package:atproto_core/atproto_core.dart' as atp_core; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +String resolvePdsHost(AuthTokens tokens) { + final oauthHost = _resolveOAuthPdsHost(tokens); + if (oauthHost != null) { + return oauthHost; + } + + final storedHost = normalizeAtprotoServiceHost(tokens.service); + if (storedHost != null) { + return storedHost; + } + + return 'Unknown'; +} + +String? normalizeAtprotoServiceHost(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final uri = Uri.tryParse(trimmed); + if (uri != null && uri.host.isNotEmpty) { + return uri.host; + } + + return trimmed; +} + +String? _resolveOAuthPdsHost(AuthTokens tokens) { + if (!tokens.usesOAuth || + tokens.refreshToken == null || + tokens.dpopPublicKey == null || + tokens.dpopPrivateKey == null) { + return null; + } + + try { + final session = atp_core.restoreOAuthSession( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken!, + dPoPNonce: tokens.dpopNonce, + publicKey: tokens.dpopPublicKey!, + privateKey: tokens.dpopPrivateKey!, + ); + return normalizeAtprotoServiceHost(session.atprotoPdsEndpoint); + } catch (_) { + return null; + } +} diff --git a/lib/core/theme/feed_architecture.dart b/lib/core/theme/feed_architecture.dart deleted file mode 100644 --- a/lib/core/theme/feed_architecture.dart +++ /dev/null @@ -1,13 +0,0 @@ -enum FeedArchitecture { - grid, - linear; - - static FeedArchitecture fromString(String? value) { - switch (value) { - case 'linear': - return FeedArchitecture.linear; - default: - return FeedArchitecture.grid; - } - } -} diff --git a/lib/core/theme/feed_layout.dart b/lib/core/theme/feed_layout.dart new file mode 100644 --- /dev/null +++ b/lib/core/theme/feed_layout.dart @@ -0,0 +1,17 @@ +enum FeedLayout { + card, + compact; + + static FeedLayout fromString(String? value) { + switch (value) { + case 'linear': + case 'compact': + return FeedLayout.compact; + case 'grid': + case 'card': + return FeedLayout.card; + default: + return FeedLayout.card; + } + } +} diff --git a/test/core/theme/feed_architecture_test.dart b/test/core/theme/feed_architecture_test.dart --- a/test/core/theme/feed_architecture_test.dart +++ b/test/core/theme/feed_architecture_test.dart @@ -1,28 +1,28 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; void main() { - group('FeedArchitecture', () { + group('FeedLayout', () { group('fromString', () { test('parses grid', () { - expect(FeedArchitecture.fromString('grid'), FeedArchitecture.grid); + expect(FeedLayout.fromString('grid'), FeedLayout.card); }); test('parses linear', () { - expect(FeedArchitecture.fromString('linear'), FeedArchitecture.linear); + expect(FeedLayout.fromString('linear'), FeedLayout.compact); }); test('null returns grid', () { - expect(FeedArchitecture.fromString(null), FeedArchitecture.grid); + expect(FeedLayout.fromString(null), FeedLayout.card); }); test('unknown value returns grid', () { - expect(FeedArchitecture.fromString('unknown'), FeedArchitecture.grid); + expect(FeedLayout.fromString('unknown'), FeedLayout.card); }); test('round-trips all values via name', () { - for (final arch in FeedArchitecture.values) { - expect(FeedArchitecture.fromString(arch.name), arch, reason: 'arch: $arch'); + for (final arch in FeedLayout.values) { + expect(FeedLayout.fromString(arch.name), arch, reason: 'arch: $arch'); } }); }); diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -10,6 +10,7 @@ import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/atproto_host_resolver.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -375,6 +376,7 @@ 'AuthRepository: OAuth session will target PDS ' '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', ); + final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? oauthService; try { final authSession = await createAtProtoForOAuthSession(session).server.getSession(); @@ -401,7 +403,7 @@ did: session.sub, handle: resolvedHandle, displayName: displayName, - service: oauthService, + service: pdsHost, dpopNonce: session.$dPoPNonce, dpopPublicKey: session.$publicKey, dpopPrivateKey: session.$privateKey, diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -7,7 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/router/app_shell.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; @@ -19,9 +19,6 @@ import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; -import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; -import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; -import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; @@ -31,6 +28,9 @@ import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -629,9 +629,9 @@ } return BlocBuilder( - buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, builder: (context, settingsState) { - if (settingsState.feedArchitecture == FeedArchitecture.grid) { + if (settingsState.feedLayout == FeedLayout.card) { return _buildGridFeed(context, feedState); } return _buildLinearFeed(context, feedState); diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -1,7 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.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/core/theme/feed_layout.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; class SettingsCubit extends Cubit { @@ -10,7 +10,7 @@ AppThemePalette? initialPalette, AppThemeVariant? initialVariant, bool? initialUseSystemTheme, - FeedArchitecture? initialFeedArchitecture, + FeedLayout? initialFeedLayout, bool? initialSimulateOffline, int? initialThreadAutoCollapseDepth, }) : super( @@ -18,7 +18,7 @@ themePalette: initialPalette ?? AppThemePalette.oxocarbon, themeVariant: initialVariant ?? AppThemeVariant.dark, useSystemTheme: initialUseSystemTheme ?? false, - feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, + feedLayout: initialFeedLayout ?? FeedLayout.card, simulateOffline: initialSimulateOffline ?? false, threadAutoCollapseDepth: initialThreadAutoCollapseDepth, ), @@ -29,7 +29,8 @@ static const String _keyThemePalette = 'theme_palette'; static const String _keyThemeVariant = 'theme_variant'; static const String _keyUseSystemTheme = 'use_system_theme'; - static const String _keyFeedArchitecture = 'feed_architecture'; + static const String _keyFeedLayout = 'feed_layout'; + static const String _legacyKeyFeedArchitecture = 'feed_architecture'; static const String _keySimulateOffline = 'simulate_offline'; static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; @@ -37,7 +38,8 @@ final paletteStr = await database.getSetting(_keyThemePalette); final variantStr = await database.getSetting(_keyThemeVariant); final useSystemStr = await database.getSetting(_keyUseSystemTheme); - final feedArchStr = await database.getSetting(_keyFeedArchitecture); + final feedLayoutStr = + await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); final simulateOfflineStr = await database.getSetting(_keySimulateOffline); final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); @@ -46,7 +48,7 @@ themePalette: AppTheme.parsePalette(paletteStr), themeVariant: AppTheme.parseVariant(variantStr), useSystemTheme: useSystemStr == 'true', - feedArchitecture: FeedArchitecture.fromString(feedArchStr), + feedLayout: FeedLayout.fromString(feedLayoutStr), simulateOffline: simulateOfflineStr == 'true', threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), ), @@ -74,9 +76,10 @@ emit(state.copyWith(useSystemTheme: value)); } - Future setFeedArchitecture(FeedArchitecture architecture) async { - await database.setSetting(_keyFeedArchitecture, architecture.name); - emit(state.copyWith(feedArchitecture: architecture)); + Future setFeedLayout(FeedLayout layout) async { + await database.setSetting(_keyFeedLayout, layout.name); + await database.deleteSetting(_legacyKeyFeedArchitecture); + emit(state.copyWith(feedLayout: layout)); } Future setSimulateOffline(bool value) async { diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; const Object _threadAutoCollapseDepthUnset = Object(); @@ -9,7 +9,7 @@ required this.themePalette, required this.themeVariant, required this.useSystemTheme, - this.feedArchitecture = FeedArchitecture.grid, + this.feedLayout = FeedLayout.card, this.simulateOffline = false, this.threadAutoCollapseDepth, }); @@ -17,7 +17,7 @@ final AppThemePalette themePalette; final AppThemeVariant themeVariant; final bool useSystemTheme; - final FeedArchitecture feedArchitecture; + final FeedLayout feedLayout; final bool simulateOffline; final int? threadAutoCollapseDepth; @@ -25,7 +25,7 @@ AppThemePalette? themePalette, AppThemeVariant? themeVariant, bool? useSystemTheme, - FeedArchitecture? feedArchitecture, + FeedLayout? feedLayout, bool? simulateOffline, Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, }) { @@ -33,7 +33,7 @@ themePalette: themePalette ?? this.themePalette, themeVariant: themeVariant ?? this.themeVariant, useSystemTheme: useSystemTheme ?? this.useSystemTheme, - feedArchitecture: feedArchitecture ?? this.feedArchitecture, + feedLayout: feedLayout ?? this.feedLayout, simulateOffline: simulateOffline ?? this.simulateOffline, threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) ? this.threadAutoCollapseDepth @@ -46,7 +46,7 @@ themePalette, themeVariant, useSystemTheme, - feedArchitecture, + feedLayout, simulateOffline, threadAutoCollapseDepth, ]; 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 @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/atproto_host_resolver.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; @@ -227,17 +228,17 @@ ), child: Column( children: [ - _SettingsDropdownTile( - title: 'Feed Architecture', - value: state.feedArchitecture, - options: FeedArchitecture.values, - labelBuilder: (architecture) => switch (architecture) { - FeedArchitecture.grid => 'Grid', - FeedArchitecture.linear => 'Linear', + _SettingsDropdownTile( + title: 'Feed Layout', + value: state.feedLayout, + options: FeedLayout.values, + labelBuilder: (layout) => switch (layout) { + FeedLayout.card => 'Card', + FeedLayout.compact => 'Compact', }, onChanged: (value) { if (value != null) { - settingsCubit.setFeedArchitecture(value); + settingsCubit.setFeedLayout(value); } }, ), @@ -371,7 +372,7 @@ return const SizedBox.shrink(); } - final pds = tokens.service?.trim().isNotEmpty == true ? tokens.service!.trim() : 'bsky.social'; + final pds = resolvePdsHost(tokens); return Container( decoration: BoxDecoration( diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -28,11 +28,11 @@ class MockAuthBloc extends MockBloc implements AuthBloc {} -SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( +SettingsState _settingsState(FeedLayout architecture) => SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: architecture, + feedLayout: architecture, ); const _homeFeedState = FeedPreferencesState.loaded( @@ -46,7 +46,7 @@ ], ); -Widget _buildSubject({required FeedArchitecture architecture, double screenWidth = 400, int itemCount = 3}) { +Widget _buildSubject({required FeedLayout architecture, double screenWidth = 400, int itemCount = 3}) { final cubit = MockSettingsCubit(); when(() => cubit.state).thenReturn(_settingsState(architecture)); @@ -133,33 +133,33 @@ group('FeedLayoutView — grid architecture', () { testWidgets('shows SliverGrid when architecture is grid', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); expect(find.byType(SliverGrid), findsOneWidget); expect(find.byType(CustomScrollView), findsOneWidget); }); testWidgets('uses gridItemBuilder in grid mode', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); expect(find.text('grid 0'), findsOneWidget); expect(find.text('linear 0'), findsNothing); }); testWidgets('uses 1 column at width < 600', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); expect(find.byType(SliverGrid), findsNothing); expect(find.byType(SliverList), findsOneWidget); }); testWidgets('uses tighter single-column padding at phone widths', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); final padding = tester.widget(find.byType(SliverPadding)); expect(padding.padding, const EdgeInsets.fromLTRB(12, 8, 12, 12)); }); testWidgets('uses 2 columns at width 600–839', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); final grid = tester.widget(find.byType(SliverGrid)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; @@ -167,7 +167,7 @@ }); testWidgets('uses 3 columns at width 840–1199', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1000)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1000)); final grid = tester.widget(find.byType(SliverGrid)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; @@ -175,7 +175,7 @@ }); testWidgets('uses 4 columns at width >= 1200', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1400)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1400)); final grid = tester.widget(find.byType(SliverGrid)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; @@ -183,7 +183,7 @@ }); testWidgets('allocates extra height beyond the square media region', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); final grid = tester.widget(find.byType(SliverGrid)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; @@ -196,14 +196,14 @@ group('FeedLayoutView — linear architecture', () { testWidgets('shows ListView when architecture is linear', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); expect(find.byType(ListView), findsOneWidget); expect(find.byType(SliverGrid), findsNothing); }); testWidgets('uses linearItemBuilder in linear mode', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); expect(find.text('linear 0'), findsOneWidget); expect(find.text('linear 1'), findsOneWidget); @@ -211,7 +211,7 @@ }); testWidgets('uses tighter vertical spacing in linear mode', (tester) async { - await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); final listView = tester.widget(find.byType(ListView)); expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); @@ -223,7 +223,7 @@ final cubit = MockSettingsCubit(); final streamController = StreamController.broadcast(); - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); when(() => cubit.stream).thenAnswer((_) => streamController.stream); var buildCount = 0; @@ -251,8 +251,8 @@ expect(find.byType(SliverGrid), findsOneWidget); - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); - streamController.add(_settingsState(FeedArchitecture.linear)); + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); + streamController.add(_settingsState(FeedLayout.compact)); await tester.pump(); expect(find.byType(SliverGrid), findsNothing); @@ -264,7 +264,7 @@ testWidgets('loading indicator appears when isLoadingMore is true in grid mode', (tester) async { final cubit = MockSettingsCubit(); - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); await tester.pumpWidget( MediaQuery( @@ -292,7 +292,7 @@ testWidgets('loading indicator appears when isLoadingMore is true in linear mode', (tester) async { final cubit = MockSettingsCubit(); - when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); await tester.pumpWidget( MediaQuery( diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -10,7 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; @@ -19,8 +19,8 @@ import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; -import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; @@ -78,14 +78,14 @@ themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: FeedArchitecture.grid, + feedLayout: FeedLayout.card, ); - SettingsState settingsStateWith(FeedArchitecture architecture) => SettingsState( + SettingsState settingsStateWith(FeedLayout architecture) => SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: architecture, + feedLayout: architecture, ); setUp(() { @@ -427,8 +427,8 @@ testWidgets('grid mode shows centered large grid cards without the metadata info card', (tester) async { final cubit = MockSettingsCubit(); - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); - whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedArchitecture.grid)); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); + whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedLayout.card)); await tester.pumpWidget(buildWithPosts(tester, cubit)); await tester.pump(); @@ -442,8 +442,8 @@ testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { final cubit = MockSettingsCubit(); - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); - whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedArchitecture.linear)); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); + whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedLayout.compact)); await tester.pumpWidget(buildWithPosts(tester, cubit)); await tester.pump(); @@ -457,7 +457,7 @@ final cubit = MockSettingsCubit(); final streamCtrl = StreamController.broadcast(); - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); when(() => cubit.stream).thenAnswer((_) => streamCtrl.stream); await tester.pumpWidget(buildWithPosts(tester, cubit)); @@ -466,8 +466,8 @@ expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); - when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); - streamCtrl.add(settingsStateWith(FeedArchitecture.linear)); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); + streamCtrl.add(settingsStateWith(FeedLayout.compact)); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -3,7 +3,7 @@ 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/core/theme/feed_layout.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -24,7 +24,7 @@ expect(cubit.state.themePalette, AppThemePalette.oxocarbon); expect(cubit.state.themeVariant, AppThemeVariant.dark); expect(cubit.state.useSystemTheme, false); - expect(cubit.state.feedArchitecture, FeedArchitecture.grid); + expect(cubit.state.feedLayout, FeedLayout.card); expect(cubit.state.simulateOffline, false); expect(cubit.state.threadAutoCollapseDepth, isNull); }); @@ -35,14 +35,14 @@ initialPalette: AppThemePalette.catppuccin, initialVariant: AppThemeVariant.light, initialUseSystemTheme: true, - initialFeedArchitecture: FeedArchitecture.linear, + initialFeedLayout: FeedLayout.compact, initialSimulateOffline: true, initialThreadAutoCollapseDepth: 3, ); expect(cubit.state.themePalette, AppThemePalette.catppuccin); expect(cubit.state.themeVariant, AppThemeVariant.light); expect(cubit.state.useSystemTheme, true); - expect(cubit.state.feedArchitecture, FeedArchitecture.linear); + expect(cubit.state.feedLayout, FeedLayout.compact); expect(cubit.state.simulateOffline, true); expect(cubit.state.threadAutoCollapseDepth, 3); }); @@ -64,7 +64,7 @@ .having((s) => s.themePalette, 'themePalette', AppThemePalette.nord) .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) .having((s) => s.useSystemTheme, 'useSystemTheme', true) - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) .having((s) => s.simulateOffline, 'simulateOffline', true) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), ], @@ -79,7 +79,7 @@ .having((s) => s.themePalette, 'themePalette', AppThemePalette.oxocarbon) .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) .having((s) => s.useSystemTheme, 'useSystemTheme', false) - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) .having((s) => s.simulateOffline, 'simulateOffline', false) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), ], @@ -134,26 +134,38 @@ ); blocTest( - 'setFeedArchitecture updates state and persists to database', + 'setFeedLayout updates state and persists to database', build: () => SettingsCubit(database: database), - act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.linear), - expect: () => [ - isA().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), - ], + act: (cubit) => cubit.setFeedLayout(FeedLayout.compact), + expect: () => [isA().having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact)], verify: (cubit) async { - final value = await database.getSetting('feed_architecture'); - expect(value, 'linear'); + final value = await database.getSetting('feed_layout'); + expect(value, 'compact'); }, ); blocTest( - 'setFeedArchitecture grid updates state and persists to database', - build: () => SettingsCubit(database: database, initialFeedArchitecture: FeedArchitecture.linear), - act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.grid), - expect: () => [isA().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid)], + 'setFeedLayout card updates state and persists to database', + build: () => SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact), + act: (cubit) => cubit.setFeedLayout(FeedLayout.card), + expect: () => [isA().having((s) => s.feedLayout, 'feedLayout', FeedLayout.card)], verify: (cubit) async { - final value = await database.getSetting('feed_architecture'); - expect(value, 'grid'); + final value = await database.getSetting('feed_layout'); + expect(value, 'card'); + }, + ); + + blocTest( + 'setFeedLayout clears the legacy feed_architecture setting', + build: () => SettingsCubit(database: database), + setUp: () async { + await database.setSetting('feed_architecture', 'linear'); + }, + act: (cubit) => cubit.setFeedLayout(FeedLayout.compact), + expect: () => [isA().having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact)], + verify: (cubit) async { + expect(await database.getSetting('feed_layout'), 'compact'); + expect(await database.getSetting('feed_architecture'), isNull); }, ); @@ -194,16 +206,16 @@ ); blocTest( - 'loadSettings round-trips feed_architecture and thread auto-collapse depth', + 'loadSettings round-trips feed_layout and thread auto-collapse depth', build: () => SettingsCubit(database: database), setUp: () async { - await database.setSetting('feed_architecture', 'linear'); + await database.setSetting('feed_layout', 'linear'); await database.setSetting('thread_auto_collapse_depth', '6'); }, act: (cubit) => cubit.loadSettings(), expect: () => [ isA() - .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) + .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 6), ], ); diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; void main() { @@ -65,18 +65,18 @@ expect(state1, isNot(equals(state2))); }); - test('inequality when feedArchitecture differs', () { + test('inequality when feedLayout differs', () { const state1 = SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: FeedArchitecture.grid, + feedLayout: FeedLayout.card, ); const state2 = SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: FeedArchitecture.linear, + feedLayout: FeedLayout.compact, ); expect(state1, isNot(equals(state2))); @@ -127,7 +127,7 @@ themePalette: AppThemePalette.nord, themeVariant: AppThemeVariant.light, useSystemTheme: true, - feedArchitecture: FeedArchitecture.linear, + feedLayout: FeedLayout.compact, simulateOffline: true, threadAutoCollapseDepth: 3, ); @@ -135,7 +135,7 @@ expect(updated.themePalette, AppThemePalette.nord); expect(updated.themeVariant, AppThemeVariant.light); expect(updated.useSystemTheme, true); - expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.feedLayout, FeedLayout.compact); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 3); expect(original.themePalette, AppThemePalette.oxocarbon); @@ -146,7 +146,7 @@ themePalette: AppThemePalette.catppuccin, themeVariant: AppThemeVariant.light, useSystemTheme: true, - feedArchitecture: FeedArchitecture.linear, + feedLayout: FeedLayout.compact, simulateOffline: true, threadAutoCollapseDepth: 4, ); @@ -156,7 +156,7 @@ expect(updated.themePalette, AppThemePalette.catppuccin); expect(updated.themeVariant, AppThemeVariant.light); expect(updated.useSystemTheme, true); - expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.feedLayout, FeedLayout.compact); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 4); }); @@ -179,7 +179,7 @@ themePalette: AppThemePalette.rosePine, themeVariant: AppThemeVariant.light, useSystemTheme: true, - feedArchitecture: FeedArchitecture.linear, + feedLayout: FeedLayout.compact, simulateOffline: true, threadAutoCollapseDepth: 6, ); @@ -187,18 +187,18 @@ expect(state.props, contains(AppThemePalette.rosePine)); expect(state.props, contains(AppThemeVariant.light)); expect(state.props, contains(true)); - expect(state.props, contains(FeedArchitecture.linear)); + expect(state.props, contains(FeedLayout.compact)); expect(state.props, contains(true)); expect(state.props, contains(6)); }); - test('defaults feedArchitecture to grid', () { + test('defaults feedLayout to card', () { const state = SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, ); - expect(state.feedArchitecture, FeedArchitecture.grid); + expect(state.feedLayout, FeedLayout.card); }); test('defaults simulateOffline to false', () { 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 @@ -1,10 +1,12 @@ +import 'dart:convert'; + import 'package:bloc_test/bloc_test.dart'; 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/core/theme/feed_layout.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'; @@ -43,7 +45,7 @@ themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: FeedArchitecture.grid, + feedLayout: FeedLayout.card, ), ); whenListen( @@ -53,7 +55,7 @@ themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, - feedArchitecture: FeedArchitecture.grid, + feedLayout: FeedLayout.card, ), ); }); @@ -76,17 +78,25 @@ expect(find.text('APPEARANCE'), findsOneWidget); expect(find.text('System'), findsOneWidget); expect(find.text('LAYOUT'), findsOneWidget); - expect(find.text('Feed Architecture'), findsOneWidget); + expect(find.text('Feed Layout'), 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', + final tokens = AuthTokens( + accessToken: _buildJwt( + aud: 'shaggymane.us-west.host.bsky.network', + sub: 'did:plc:lazurite123', + clientId: 'https://client.example/metadata.json', + iss: 'https://bsky.social', + ), refreshToken: 'refresh-token', did: 'did:plc:lazurite123', handle: 'owais.bsky.social', - service: 'https://pds.example.com', + service: 'bsky.social', + dpopPublicKey: 'public-key', + dpopPrivateKey: 'private-key', + authMethod: AuthMethod.oauth, ); final account = Account( did: tokens.did, @@ -103,8 +113,8 @@ 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(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); + whenListen(authBloc, const Stream.empty(), initialState: AuthState.authenticated(tokens)); when( () => accountSwitcherCubit.state, ).thenReturn(AccountSwitcherState.ready(accounts: [account], activeDid: account.did)); @@ -125,7 +135,7 @@ 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); + expect(find.text('shaggymane.us-west.host.bsky.network'), findsOneWidget); }); testWidgets('does not render removed placeholder settings', (tester) async { @@ -139,4 +149,23 @@ expect(find.text('Email Notifications'), findsNothing); expect(find.text('Help & Support'), findsNothing); }); +} + +String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { + final header = _base64UrlEncode({'alg': 'none', 'typ': 'JWT'}); + final payload = _base64UrlEncode({ + 'aud': aud, + 'sub': sub, + 'client_id': clientId, + 'scope': 'atproto transition:generic', + 'iss': iss, + 'exp': DateTime.now().toUtc().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, + 'iat': DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000, + }); + + return '$header.$payload.signature'; +} + +String _base64UrlEncode(Map value) { + return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); } diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -9,11 +9,11 @@ const double _gridCardChromeHeight = 160; /// Renders a scrollable list of items in either a responsive [SliverGrid] -/// (grid architecture) or a padded [ListView] (linear architecture), driven -/// by [SettingsCubit.feedArchitecture]. +/// (card layout) or a padded [ListView] (compact layout), driven +/// by [SettingsCubit.feedLayout]. /// -/// [gridItemBuilder] is used when the grid architecture is active. -/// [linearItemBuilder] is used when the linear architecture is active. +/// [gridItemBuilder] is used when the card layout is active. +/// [linearItemBuilder] is used when the compact layout is active. /// This allows the caller to render the appropriate card variant for each mode. class FeedLayoutView extends StatelessWidget { const FeedLayoutView({ @@ -36,9 +36,9 @@ @override Widget build(BuildContext context) { return BlocBuilder( - buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, builder: (context, settingsState) { - if (settingsState.feedArchitecture == FeedArchitecture.grid) { + if (settingsState.feedLayout == FeedLayout.card) { return _buildGrid(context); } return _buildLinear(context); -- tangled.sh