mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: video upload limits & suggested follows screen

+656 -18
+16 -16
docs/tasks/phase-5.md
··· 19 19 20 20 ### UI 21 21 22 - - [ ] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row 23 - - [ ] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 24 - - [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) 25 - - [ ] Infinite scroll pagination for starter packs tab 22 + - [x] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row 23 + - [x] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 24 + - [x] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) 25 + - [x] Infinite scroll pagination for starter packs tab 26 26 27 27 ### Tests 28 28 29 29 - [x] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 30 - - [ ] Widget tests: third tab renders, results display, empty state, tap navigation 30 + - [x] Widget tests: third tab renders, results display, empty state, tap navigation 31 31 32 32 ## M21 - Suggested Follows Sheet 33 33 ··· 41 41 42 42 ### UI 43 43 44 - - [ ] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 - - [ ] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile 46 - - [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` 47 - - [ ] Tap profile tile → pop sheet, navigate to profile screen 48 - - [ ] Empty state when no suggestions returned 44 + - [x] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 + - [x] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile 46 + - [x] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` 47 + - [x] Tap profile tile → pop sheet, navigate to profile screen 48 + - [x] Empty state when no suggestions returned 49 49 50 50 ### Tests 51 51 52 52 - [x] Unit tests: repository method, cubit state transitions 53 - - [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 53 + - [x] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 54 54 55 55 ## M22 - Video Upload Limits 56 56 ··· 64 64 65 65 ### UI 66 66 67 - - [ ] Settings screen - new tile in Account section: "Video Upload Limits" 68 - - [ ] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 69 - - [ ] Loading state while fetching, error state if request fails 70 - - [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false 67 + - [x] Settings screen - new tile in Account section: "Video Upload Limits" 68 + - [x] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 69 + - [x] Loading state while fetching, error state if request fails 70 + - [x] Display server `message` if present; show `error` text with warning styling if `canUpload` is false 71 71 72 72 ### Tests 73 73 74 74 - [x] Unit tests: repository method, cubit state transitions and formatting 75 - - [ ] Widget tests: tile renders limits, loading indicator, error state, message display 75 + - [x] Widget tests: tile renders limits, loading indicator, error state, message display 76 76 77 77 ## M23 - Profile Context (Constellation) 78 78
+10
lib/core/router/app_router.dart
··· 50 50 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 51 51 import 'package:lazurite/core/network/constellation_client.dart'; 52 52 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 53 + import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 54 + import 'package:lazurite/features/settings/data/video_repository.dart'; 53 55 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 54 56 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 57 + import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; 55 58 56 59 class AppRouter { 57 60 AppRouter({required this.authBloc, this.navigatorObserver}); ··· 258 261 GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 259 262 GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 260 263 GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 264 + GoRoute( 265 + path: 'video-limits', 266 + builder: (context, state) => BlocProvider( 267 + create: (_) => VideoUploadLimitsCubit(repository: context.read<VideoRepository>()), 268 + child: const VideoUploadLimitsScreen(), 269 + ), 270 + ), 261 271 ], 262 272 ), 263 273 ],
+28
lib/features/profile/presentation/profile_screen.dart
··· 24 24 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 25 25 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 26 26 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 27 + import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 27 28 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 29 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 28 30 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 31 + import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; 29 32 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 30 33 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 31 34 import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; ··· 533 536 }, 534 537 ), 535 538 ListTile( 539 + leading: const Icon(Icons.people_outline), 540 + title: const Text('Suggested Follows'), 541 + onTap: () { 542 + Navigator.pop(sheetContext); 543 + _showSuggestedFollows(context, profile); 544 + }, 545 + ), 546 + ListTile( 536 547 leading: const Icon(Icons.hub_outlined), 537 548 title: const Text('Profile Context'), 538 549 onTap: () { ··· 624 635 ), 625 636 ), 626 637 ), 638 + ).whenComplete(cubit.close); 639 + } 640 + 641 + void _showSuggestedFollows(BuildContext context, ProfileViewDetailed profile) { 642 + ProfileRepository? profileRepository; 643 + try { 644 + profileRepository = context.read<ProfileRepository>(); 645 + } catch (_) { 646 + return; 647 + } 648 + 649 + final cubit = SuggestedFollowsCubit(repository: profileRepository)..load(profile.did); 650 + 651 + showModalBottomSheet<void>( 652 + context: context, 653 + isScrollControlled: true, 654 + builder: (sheetContext) => BlocProvider.value(value: cubit, child: const SuggestedFollowsSheet()), 627 655 ).whenComplete(cubit.close); 628 656 } 629 657
+100
lib/features/profile/presentation/widgets/suggested_follows_sheet.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 6 + 7 + class SuggestedFollowsSheet extends StatelessWidget { 8 + const SuggestedFollowsSheet({super.key}); 9 + 10 + @override 11 + Widget build(BuildContext context) { 12 + return DraggableScrollableSheet( 13 + initialChildSize: 0.6, 14 + maxChildSize: 0.9, 15 + minChildSize: 0.3, 16 + expand: false, 17 + builder: (_, scrollController) => Column( 18 + children: [ 19 + Padding( 20 + padding: const EdgeInsets.symmetric(vertical: 12), 21 + child: Text( 22 + 'Suggested Follows', 23 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 24 + ), 25 + ), 26 + const Divider(height: 1), 27 + Expanded( 28 + child: BlocBuilder<SuggestedFollowsCubit, SuggestedFollowsState>( 29 + builder: (context, state) { 30 + if (state.isLoading) { 31 + return const Center(child: CircularProgressIndicator()); 32 + } 33 + 34 + if (state.hasError) { 35 + return Center(child: Text(state.errorMessage ?? 'Failed to load suggestions')); 36 + } 37 + 38 + if (state.isEmpty) { 39 + return const Center(child: Text('No suggestions found')); 40 + } 41 + 42 + return ListView.builder( 43 + controller: scrollController, 44 + itemCount: state.suggestions.length, 45 + itemBuilder: (context, index) { 46 + final profile = state.suggestions[index]; 47 + return _SuggestedProfileTile(profile: profile); 48 + }, 49 + ); 50 + }, 51 + ), 52 + ), 53 + ], 54 + ), 55 + ); 56 + } 57 + } 58 + 59 + class _SuggestedProfileTile extends StatelessWidget { 60 + const _SuggestedProfileTile({required this.profile}); 61 + 62 + final ProfileView profile; 63 + 64 + @override 65 + Widget build(BuildContext context) { 66 + final isFollowing = profile.viewer?.following != null; 67 + 68 + return ListTile( 69 + leading: CircleAvatar( 70 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 71 + child: profile.avatar == null 72 + ? Text((profile.displayName ?? profile.handle).substring(0, 1).toUpperCase()) 73 + : null, 74 + ), 75 + title: Text(profile.displayName ?? profile.handle), 76 + subtitle: Text('@${profile.handle}'), 77 + trailing: _FollowButton(profile: profile, isFollowing: isFollowing), 78 + onTap: () { 79 + Navigator.of(context).pop(); 80 + context.push('/profile?actor=${Uri.encodeComponent(profile.did)}'); 81 + }, 82 + ); 83 + } 84 + } 85 + 86 + class _FollowButton extends StatelessWidget { 87 + const _FollowButton({required this.profile, required this.isFollowing}); 88 + 89 + final ProfileView profile; 90 + final bool isFollowing; 91 + 92 + @override 93 + Widget build(BuildContext context) { 94 + if (isFollowing) { 95 + return const OutlinedButton(onPressed: null, child: Text('Following')); 96 + } 97 + 98 + return const FilledButton(onPressed: null, child: Text('Follow')); 99 + } 100 + }
+6
lib/features/settings/presentation/settings_screen.dart
··· 86 86 subtitle: 'View your saved posts', 87 87 onTap: () => context.push('/saved'), 88 88 ), 89 + _SettingsTile( 90 + icon: Icons.videocam_outlined, 91 + title: 'Video Upload Limits', 92 + subtitle: 'Check your daily video quota', 93 + onTap: () => context.push('/settings/video-limits'), 94 + ), 89 95 const SizedBox(height: 24), 90 96 _buildSectionHeader(context, 'Advanced'), 91 97 _buildAdvancedSettings(context),
+144
lib/features/settings/presentation/video_upload_limits_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 4 + import 'package:lazurite/features/settings/data/video_repository.dart'; 5 + 6 + class VideoUploadLimitsScreen extends StatefulWidget { 7 + const VideoUploadLimitsScreen({super.key}); 8 + 9 + @override 10 + State<VideoUploadLimitsScreen> createState() => _VideoUploadLimitsScreenState(); 11 + } 12 + 13 + class _VideoUploadLimitsScreenState extends State<VideoUploadLimitsScreen> { 14 + @override 15 + void initState() { 16 + super.initState(); 17 + context.read<VideoUploadLimitsCubit>().fetch(); 18 + } 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + return Scaffold( 23 + appBar: AppBar(title: const Text('Video Upload Limits')), 24 + body: BlocBuilder<VideoUploadLimitsCubit, VideoUploadLimitsState>( 25 + builder: (context, state) { 26 + if (state.isLoading) { 27 + return const Center(child: CircularProgressIndicator()); 28 + } 29 + 30 + if (state.hasError) { 31 + return Center( 32 + child: Padding( 33 + padding: const EdgeInsets.all(24), 34 + child: Column( 35 + mainAxisSize: MainAxisSize.min, 36 + children: [ 37 + Icon(Icons.warning_amber_outlined, size: 48, color: Theme.of(context).colorScheme.error), 38 + const SizedBox(height: 16), 39 + Text( 40 + state.errorMessage ?? 'Failed to load video upload limits', 41 + textAlign: TextAlign.center, 42 + style: TextStyle(color: Theme.of(context).colorScheme.error), 43 + ), 44 + ], 45 + ), 46 + ), 47 + ); 48 + } 49 + 50 + final limits = state.limits; 51 + if (limits == null) { 52 + return const SizedBox.shrink(); 53 + } 54 + 55 + return _VideoUploadLimitsBody(limits: limits); 56 + }, 57 + ), 58 + ); 59 + } 60 + } 61 + 62 + class _VideoUploadLimitsBody extends StatelessWidget { 63 + const _VideoUploadLimitsBody({required this.limits}); 64 + 65 + final VideoUploadLimits limits; 66 + 67 + String _formatBytes(int bytes) { 68 + if (bytes >= 1024 * 1024 * 1024) { 69 + final gb = bytes / (1024 * 1024 * 1024); 70 + return '${gb.toStringAsFixed(2)} GB'; 71 + } 72 + final mb = bytes / (1024 * 1024); 73 + return '${mb.toStringAsFixed(2)} MB'; 74 + } 75 + 76 + @override 77 + Widget build(BuildContext context) { 78 + final theme = Theme.of(context); 79 + final canUploadColor = limits.canUpload ? theme.colorScheme.primary : theme.colorScheme.error; 80 + final canUploadLabel = limits.canUpload ? 'Uploads enabled' : 'Uploads disabled'; 81 + final canUploadIcon = limits.canUpload ? Icons.check_circle_outline : Icons.cancel_outlined; 82 + 83 + return ListView( 84 + padding: const EdgeInsets.all(16), 85 + children: [ 86 + Row( 87 + children: [ 88 + Icon(canUploadIcon, color: canUploadColor), 89 + const SizedBox(width: 8), 90 + Text(canUploadLabel, style: theme.textTheme.titleMedium?.copyWith(color: canUploadColor)), 91 + ], 92 + ), 93 + const SizedBox(height: 24), 94 + if (limits.remainingDailyVideos != null) ...[ 95 + _LimitRow(label: 'Remaining videos today', value: '${limits.remainingDailyVideos}'), 96 + const Divider(), 97 + ], 98 + if (limits.remainingDailyBytes != null) ...[ 99 + _LimitRow(label: 'Remaining storage today', value: _formatBytes(limits.remainingDailyBytes!)), 100 + const Divider(), 101 + ], 102 + if (limits.message != null) ...[ 103 + const SizedBox(height: 8), 104 + Text(limits.message!, style: theme.textTheme.bodyMedium), 105 + const SizedBox(height: 8), 106 + ], 107 + if (limits.error != null) ...[ 108 + const SizedBox(height: 8), 109 + Row( 110 + crossAxisAlignment: CrossAxisAlignment.start, 111 + children: [ 112 + Icon(Icons.warning_amber_outlined, color: theme.colorScheme.error, size: 20), 113 + const SizedBox(width: 8), 114 + Expanded( 115 + child: Text(limits.error!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error)), 116 + ), 117 + ], 118 + ), 119 + ], 120 + ], 121 + ); 122 + } 123 + } 124 + 125 + class _LimitRow extends StatelessWidget { 126 + const _LimitRow({required this.label, required this.value}); 127 + 128 + final String label; 129 + final String value; 130 + 131 + @override 132 + Widget build(BuildContext context) { 133 + return Padding( 134 + padding: const EdgeInsets.symmetric(vertical: 8), 135 + child: Row( 136 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 137 + children: [ 138 + Text(label, style: Theme.of(context).textTheme.bodyLarge), 139 + Text(value, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), 140 + ], 141 + ), 142 + ); 143 + } 144 + }
+2
lib/main.dart
··· 38 38 import 'package:lazurite/features/search/data/search_repository.dart'; 39 39 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 40 40 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 41 + import 'package:lazurite/features/settings/data/video_repository.dart'; 41 42 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 42 43 43 44 Future<void> main() async { ··· 258 259 RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)), 259 260 RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 260 261 RepositoryProvider(create: (_) => PostActionCache()), 262 + RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), 261 263 RepositoryProvider.value(value: bluesky), 262 264 RepositoryProvider.value(value: widget.database), 263 265 RepositoryProvider.value(value: accountDid),
+58 -2
test/features/profile/presentation/profile_screen_test.dart
··· 391 391 FeedState feedStateWith(List<FeedViewPost> p) => 392 392 FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); 393 393 394 - /// Builds the profile screen with [posts] in the feed and the given 395 - /// [settCubit] controlling layout mode. 394 + /// Builds the profile screen with [posts] in the feed and the given SettingsCubit controlling layout mode. 396 395 Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { 397 396 useLargeScreen(tester); 398 397 ··· 565 564 await tester.pumpAndSettle(); 566 565 567 566 expect(find.text('Add to list'), findsOneWidget); 567 + }); 568 + }); 569 + 570 + group('Suggested Follows overflow menu', () { 571 + testWidgets('other profile overflow menu shows Suggested Follows option', (tester) async { 572 + useLargeScreen(tester); 573 + const otherProfile = ProfileViewDetailed( 574 + did: 'did:plc:other', 575 + handle: 'other.bsky.social', 576 + displayName: 'Other User', 577 + ); 578 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 579 + whenListen( 580 + profileBloc, 581 + const Stream<ProfileState>.empty(), 582 + initialState: const ProfileState.loaded(profile: otherProfile), 583 + ); 584 + 585 + final mockProfileActionRepository = MockProfileActionRepository(); 586 + 587 + await tester.pumpWidget( 588 + MultiRepositoryProvider( 589 + providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 590 + child: MultiBlocProvider( 591 + providers: [ 592 + BlocProvider<AuthBloc>.value(value: authBloc), 593 + BlocProvider<ProfileBloc>.value(value: profileBloc), 594 + BlocProvider<FeedBloc>.value(value: feedBloc), 595 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 596 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 597 + ], 598 + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 599 + ), 600 + ), 601 + ); 602 + 603 + await tester.pumpAndSettle(); 604 + 605 + await tester.tap(find.byIcon(Icons.more_vert)); 606 + await tester.pumpAndSettle(); 607 + 608 + await tester.tap(find.text('Report')); 609 + await tester.pumpAndSettle(); 610 + 611 + expect(find.text('Suggested Follows'), findsOneWidget); 612 + }); 613 + 614 + testWidgets('own profile overflow menu does NOT show Suggested Follows option', (tester) async { 615 + useLargeScreen(tester); 616 + 617 + await tester.pumpWidget(buildSubject()); 618 + await tester.pumpAndSettle(); 619 + 620 + await tester.tap(find.byIcon(Icons.more_vert)); 621 + await tester.pumpAndSettle(); 622 + 623 + expect(find.text('Suggested Follows'), findsNothing); 568 624 }); 569 625 }); 570 626 }
+117
test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 7 + import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockSuggestedFollowsCubit extends MockCubit<SuggestedFollowsState> implements SuggestedFollowsCubit {} 11 + 12 + ProfileView _profile(String did, {String? displayName}) => 13 + ProfileView(did: did, handle: '$did.bsky.social', displayName: displayName, indexedAt: DateTime.utc(2026)); 14 + 15 + void main() { 16 + late MockSuggestedFollowsCubit cubit; 17 + 18 + setUp(() { 19 + cubit = MockSuggestedFollowsCubit(); 20 + }); 21 + 22 + Widget buildSubject() { 23 + return MaterialApp( 24 + home: Scaffold( 25 + body: BlocProvider<SuggestedFollowsCubit>.value(value: cubit, child: const SuggestedFollowsSheet()), 26 + ), 27 + ); 28 + } 29 + 30 + testWidgets('shows loading indicator when state is loading', (tester) async { 31 + when(() => cubit.state).thenReturn(const SuggestedFollowsState.loading()); 32 + whenListen(cubit, const Stream<SuggestedFollowsState>.empty(), initialState: const SuggestedFollowsState.loading()); 33 + 34 + await tester.pumpWidget(buildSubject()); 35 + await tester.pump(); 36 + 37 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 38 + }); 39 + 40 + testWidgets('shows error message when state has error', (tester) async { 41 + when(() => cubit.state).thenReturn(const SuggestedFollowsState.error('Something went wrong')); 42 + whenListen( 43 + cubit, 44 + const Stream<SuggestedFollowsState>.empty(), 45 + initialState: const SuggestedFollowsState.error('Something went wrong'), 46 + ); 47 + 48 + await tester.pumpWidget(buildSubject()); 49 + await tester.pump(); 50 + 51 + expect(find.text('Something went wrong'), findsOneWidget); 52 + }); 53 + 54 + testWidgets('shows empty state when loaded with no suggestions', (tester) async { 55 + when(() => cubit.state).thenReturn(const SuggestedFollowsState.loaded([])); 56 + whenListen( 57 + cubit, 58 + const Stream<SuggestedFollowsState>.empty(), 59 + initialState: const SuggestedFollowsState.loaded([]), 60 + ); 61 + 62 + await tester.pumpWidget(buildSubject()); 63 + await tester.pump(); 64 + 65 + expect(find.text('No suggestions found'), findsOneWidget); 66 + }); 67 + 68 + testWidgets('renders profile tiles when loaded with suggestions', (tester) async { 69 + final profiles = [ 70 + _profile('did:plc:bob', displayName: 'Bob Builder'), 71 + _profile('did:plc:carol', displayName: 'Carol Danvers'), 72 + ]; 73 + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 74 + whenListen( 75 + cubit, 76 + const Stream<SuggestedFollowsState>.empty(), 77 + initialState: SuggestedFollowsState.loaded(profiles), 78 + ); 79 + 80 + await tester.pumpWidget(buildSubject()); 81 + await tester.pump(); 82 + 83 + expect(find.text('Bob Builder'), findsOneWidget); 84 + expect(find.text('@did:plc:bob.bsky.social'), findsOneWidget); 85 + expect(find.text('Carol Danvers'), findsOneWidget); 86 + expect(find.text('@did:plc:carol.bsky.social'), findsOneWidget); 87 + }); 88 + 89 + testWidgets('shows Follow button for unfollowed profiles', (tester) async { 90 + final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; 91 + when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 92 + whenListen( 93 + cubit, 94 + const Stream<SuggestedFollowsState>.empty(), 95 + initialState: SuggestedFollowsState.loaded(profiles), 96 + ); 97 + 98 + await tester.pumpWidget(buildSubject()); 99 + await tester.pump(); 100 + 101 + expect(find.text('Follow'), findsOneWidget); 102 + }); 103 + 104 + testWidgets('shows sheet title', (tester) async { 105 + when(() => cubit.state).thenReturn(const SuggestedFollowsState.loaded([])); 106 + whenListen( 107 + cubit, 108 + const Stream<SuggestedFollowsState>.empty(), 109 + initialState: const SuggestedFollowsState.loaded([]), 110 + ); 111 + 112 + await tester.pumpWidget(buildSubject()); 113 + await tester.pump(); 114 + 115 + expect(find.text('Suggested Follows'), findsOneWidget); 116 + }); 117 + }
+11
test/features/settings/presentation/settings_screen_test.dart
··· 176 176 expect(find.text('Cancel'), findsOneWidget); 177 177 expect(find.text('Save'), findsOneWidget); 178 178 }); 179 + 180 + testWidgets('shows Video Upload Limits tile in Account section', (tester) async { 181 + await tester.pumpWidget(buildSubject()); 182 + await tester.pumpAndSettle(); 183 + 184 + await tester.scrollUntilVisible(find.text('Video Upload Limits'), 300); 185 + await tester.pumpAndSettle(); 186 + 187 + expect(find.text('Video Upload Limits'), findsOneWidget); 188 + expect(find.text('Check your daily video quota'), findsOneWidget); 189 + }); 179 190 } 180 191 181 192 String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) {
+164
test/features/settings/presentation/video_upload_limits_screen_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 6 + import 'package:lazurite/features/settings/data/video_repository.dart'; 7 + import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockVideoUploadLimitsCubit extends MockCubit<VideoUploadLimitsState> implements VideoUploadLimitsCubit {} 11 + 12 + void main() { 13 + late MockVideoUploadLimitsCubit cubit; 14 + 15 + setUp(() { 16 + cubit = MockVideoUploadLimitsCubit(); 17 + when(() => cubit.fetch()).thenAnswer((_) async {}); 18 + }); 19 + 20 + Widget buildSubject() { 21 + return MaterialApp( 22 + home: BlocProvider<VideoUploadLimitsCubit>.value(value: cubit, child: const VideoUploadLimitsScreen()), 23 + ); 24 + } 25 + 26 + testWidgets('shows loading indicator when state is loading', (tester) async { 27 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loading()); 28 + whenListen( 29 + cubit, 30 + const Stream<VideoUploadLimitsState>.empty(), 31 + initialState: const VideoUploadLimitsState.loading(), 32 + ); 33 + 34 + await tester.pumpWidget(buildSubject()); 35 + await tester.pump(); 36 + 37 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 38 + }); 39 + 40 + testWidgets('shows error message with warning icon when state has error', (tester) async { 41 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.error('Upload service unavailable')); 42 + whenListen( 43 + cubit, 44 + const Stream<VideoUploadLimitsState>.empty(), 45 + initialState: const VideoUploadLimitsState.error('Upload service unavailable'), 46 + ); 47 + 48 + await tester.pumpWidget(buildSubject()); 49 + await tester.pump(); 50 + 51 + expect(find.text('Upload service unavailable'), findsOneWidget); 52 + expect(find.byIcon(Icons.warning_amber_outlined), findsOneWidget); 53 + }); 54 + 55 + testWidgets('shows canUpload enabled status when uploads are allowed', (tester) async { 56 + const limits = VideoUploadLimits(canUpload: true, remainingDailyVideos: 10, remainingDailyBytes: 524288000); 57 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 58 + whenListen( 59 + cubit, 60 + const Stream<VideoUploadLimitsState>.empty(), 61 + initialState: const VideoUploadLimitsState.loaded(limits), 62 + ); 63 + 64 + await tester.pumpWidget(buildSubject()); 65 + await tester.pump(); 66 + 67 + expect(find.text('Uploads enabled'), findsOneWidget); 68 + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); 69 + }); 70 + 71 + testWidgets('shows canUpload disabled status when uploads are not allowed', (tester) async { 72 + const limits = VideoUploadLimits(canUpload: false); 73 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 74 + whenListen( 75 + cubit, 76 + const Stream<VideoUploadLimitsState>.empty(), 77 + initialState: const VideoUploadLimitsState.loaded(limits), 78 + ); 79 + 80 + await tester.pumpWidget(buildSubject()); 81 + await tester.pump(); 82 + 83 + expect(find.text('Uploads disabled'), findsOneWidget); 84 + expect(find.byIcon(Icons.cancel_outlined), findsOneWidget); 85 + }); 86 + 87 + testWidgets('shows remaining daily videos and formatted bytes', (tester) async { 88 + const limits = VideoUploadLimits(canUpload: true, remainingDailyVideos: 7, remainingDailyBytes: 524288000); 89 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 90 + whenListen( 91 + cubit, 92 + const Stream<VideoUploadLimitsState>.empty(), 93 + initialState: const VideoUploadLimitsState.loaded(limits), 94 + ); 95 + 96 + await tester.pumpWidget(buildSubject()); 97 + await tester.pump(); 98 + 99 + expect(find.text('Remaining videos today'), findsOneWidget); 100 + expect(find.text('7'), findsOneWidget); 101 + expect(find.text('Remaining storage today'), findsOneWidget); 102 + expect(find.text('500.00 MB'), findsOneWidget); 103 + }); 104 + 105 + testWidgets('formats bytes as GB when 1 GB or more', (tester) async { 106 + const limits = VideoUploadLimits(canUpload: true, remainingDailyBytes: 2147483648); 107 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 108 + whenListen( 109 + cubit, 110 + const Stream<VideoUploadLimitsState>.empty(), 111 + initialState: const VideoUploadLimitsState.loaded(limits), 112 + ); 113 + 114 + await tester.pumpWidget(buildSubject()); 115 + await tester.pump(); 116 + 117 + expect(find.text('2.00 GB'), findsOneWidget); 118 + }); 119 + 120 + testWidgets('shows server message when present', (tester) async { 121 + const limits = VideoUploadLimits(canUpload: true, message: 'Daily limit resets at midnight UTC'); 122 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 123 + whenListen( 124 + cubit, 125 + const Stream<VideoUploadLimitsState>.empty(), 126 + initialState: const VideoUploadLimitsState.loaded(limits), 127 + ); 128 + 129 + await tester.pumpWidget(buildSubject()); 130 + await tester.pump(); 131 + 132 + expect(find.text('Daily limit resets at midnight UTC'), findsOneWidget); 133 + }); 134 + 135 + testWidgets('shows error field with warning styling when limits include an error', (tester) async { 136 + const limits = VideoUploadLimits(canUpload: false, error: 'Quota exceeded'); 137 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.loaded(limits)); 138 + whenListen( 139 + cubit, 140 + const Stream<VideoUploadLimitsState>.empty(), 141 + initialState: const VideoUploadLimitsState.loaded(limits), 142 + ); 143 + 144 + await tester.pumpWidget(buildSubject()); 145 + await tester.pump(); 146 + 147 + expect(find.text('Quota exceeded'), findsOneWidget); 148 + expect(find.byIcon(Icons.warning_amber_outlined), findsOneWidget); 149 + }); 150 + 151 + testWidgets('calls fetch on initState', (tester) async { 152 + when(() => cubit.state).thenReturn(const VideoUploadLimitsState.initial()); 153 + whenListen( 154 + cubit, 155 + const Stream<VideoUploadLimitsState>.empty(), 156 + initialState: const VideoUploadLimitsState.initial(), 157 + ); 158 + 159 + await tester.pumpWidget(buildSubject()); 160 + await tester.pump(); 161 + 162 + verify(() => cubit.fetch()).called(1); 163 + }); 164 + }