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: convert PostCardWithActions to StatefulWidget and manage loading state in TypeaheadTextField

+280 -55
+65 -22
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 21 21 /// Controls which card layout variant is rendered by [PostCardWithActions]. 22 22 enum PostCardVariant { linear, grid } 23 23 24 - class PostCardWithActions extends StatelessWidget { 24 + class PostCardWithActions extends StatefulWidget { 25 25 const PostCardWithActions({ 26 26 super.key, 27 27 required this.feedViewPost, ··· 40 40 final bsky_moderation.ModerationBehaviorContext moderationContext; 41 41 42 42 @override 43 - Widget build(BuildContext context) { 44 - final post = feedViewPost.post; 43 + State<PostCardWithActions> createState() => _PostCardWithActionsState(); 44 + } 45 + 46 + class _PostCardWithActionsState extends State<PostCardWithActions> with AutomaticKeepAliveClientMixin { 47 + late PostActionCubit _postActionCubit; 48 + 49 + @override 50 + bool get wantKeepAlive => true; 51 + 52 + @override 53 + void initState() { 54 + super.initState(); 55 + _postActionCubit = _createCubit(); 56 + } 57 + 58 + @override 59 + void didUpdateWidget(covariant PostCardWithActions oldWidget) { 60 + super.didUpdateWidget(oldWidget); 61 + 62 + final previousPost = oldWidget.feedViewPost.post; 63 + final currentPost = widget.feedViewPost.post; 64 + final postIdentityChanged = 65 + previousPost.uri.toString() != currentPost.uri.toString() || previousPost.cid != currentPost.cid; 66 + if (!postIdentityChanged) { 67 + return; 68 + } 69 + 70 + _postActionCubit.close(); 71 + _postActionCubit = _createCubit(); 72 + } 73 + 74 + @override 75 + void dispose() { 76 + _postActionCubit.close(); 77 + super.dispose(); 78 + } 79 + 80 + PostActionCubit _createCubit() { 81 + final post = widget.feedViewPost.post; 45 82 final viewer = post.viewer; 46 83 47 - return BlocProvider( 48 - create: (ctx) => PostActionCubit( 49 - postActionRepository: ctx.read<PostActionRepository>(), 50 - postUri: post.uri.toString(), 51 - postCid: post.cid, 52 - isLiked: viewer?.like != null, 53 - isReposted: viewer?.repost != null, 54 - likeCount: post.likeCount ?? 0, 55 - repostCount: post.repostCount ?? 0, 56 - likeUri: viewer?.like?.toString(), 57 - repostUri: viewer?.repost?.toString(), 58 - cache: ctx.read<PostActionCache>(), 59 - ), 84 + return PostActionCubit( 85 + postActionRepository: context.read<PostActionRepository>(), 86 + postUri: post.uri.toString(), 87 + postCid: post.cid, 88 + isLiked: viewer?.like != null, 89 + isReposted: viewer?.repost != null, 90 + likeCount: post.likeCount ?? 0, 91 + repostCount: post.repostCount ?? 0, 92 + likeUri: viewer?.like?.toString(), 93 + repostUri: viewer?.repost?.toString(), 94 + cache: context.read<PostActionCache>(), 95 + ); 96 + } 97 + 98 + @override 99 + Widget build(BuildContext context) { 100 + super.build(context); 101 + return BlocProvider<PostActionCubit>.value( 102 + value: _postActionCubit, 60 103 child: _PostCardWithActionsContent( 61 - feedViewPost: feedViewPost, 62 - accountDid: accountDid, 63 - variant: variant, 64 - onDeleted: onDeleted, 65 - onReplySubmitted: onReplySubmitted, 66 - moderationContext: moderationContext, 104 + feedViewPost: widget.feedViewPost, 105 + accountDid: widget.accountDid, 106 + variant: widget.variant, 107 + onDeleted: widget.onDeleted, 108 + onReplySubmitted: widget.onReplySubmitted, 109 + moderationContext: widget.moderationContext, 67 110 ), 68 111 ); 69 112 }
+21 -1
lib/features/typeahead/presentation/typeahead_text_field.dart
··· 50 50 late FocusNode _focusNode; 51 51 late bool _ownsFocusNode; 52 52 final Object _tapRegionGroup = Object(); 53 + bool _isLoading = false; 53 54 54 55 TypeaheadCubit? _cubit; 55 56 StreamSubscription<TypeaheadState>? _stateSubscription; ··· 134 135 void _onStateChanged(TypeaheadState state) { 135 136 if (!mounted) { 136 137 return; 138 + } 139 + 140 + if (_isLoading != state.isLoading) { 141 + setState(() => _isLoading = state.isLoading); 137 142 } 138 143 139 144 if (!_focusNode.hasFocus || widget.controller.text.trim().length < widget.minChars) { ··· 253 258 254 259 @override 255 260 Widget build(BuildContext context) { 261 + final resolvedDecoration = _isLoading 262 + ? (widget.decoration ?? const InputDecoration()).copyWith( 263 + suffixIcon: const Padding( 264 + padding: EdgeInsets.all(12), 265 + child: SizedBox( 266 + key: ValueKey('typeahead-input-loading-spinner'), 267 + width: 16, 268 + height: 16, 269 + child: CircularProgressIndicator(strokeWidth: 2), 270 + ), 271 + ), 272 + suffixIconConstraints: const BoxConstraints(minWidth: 40, minHeight: 40), 273 + ) 274 + : widget.decoration; 275 + 256 276 return TapRegion( 257 277 groupId: _tapRegionGroup, 258 278 onTapOutside: _onTapOutside, ··· 262 282 key: _fieldKey, 263 283 controller: widget.controller, 264 284 focusNode: _focusNode, 265 - decoration: widget.decoration, 285 + decoration: resolvedDecoration, 266 286 validator: widget.validator, 267 287 textInputAction: widget.textInputAction, 268 288 enabled: widget.enabled,
+32 -32
pubspec.lock
··· 141 141 dependency: transitive 142 142 description: 143 143 name: build 144 - sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c 144 + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 145 145 url: "https://pub.dev" 146 146 source: hosted 147 - version: "4.0.5" 147 + version: "4.0.6" 148 148 build_config: 149 149 dependency: transitive 150 150 description: ··· 165 165 dependency: "direct dev" 166 166 description: 167 167 name: build_runner 168 - sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" 168 + sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d" 169 169 url: "https://pub.dev" 170 170 source: hosted 171 - version: "2.13.1" 171 + version: "2.14.1" 172 172 built_collection: 173 173 dependency: transitive 174 174 description: ··· 221 221 dependency: "direct main" 222 222 description: 223 223 name: chewie 224 - sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca" 224 + sha256: "53dadd2c5b6748742d7744072b38a417ad22691ca55715850300ee793dc7cb27" 225 225 url: "https://pub.dev" 226 226 source: hosted 227 - version: "1.13.0" 227 + version: "1.13.1" 228 228 cli_config: 229 229 dependency: transitive 230 230 description: ··· 257 257 url: "https://pub.dev" 258 258 source: hosted 259 259 version: "1.0.0" 260 - code_builder: 261 - dependency: transitive 262 - description: 263 - name: code_builder 264 - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" 265 - url: "https://pub.dev" 266 - source: hosted 267 - version: "4.11.1" 268 260 collection: 269 261 dependency: transitive 270 262 description: ··· 277 269 dependency: "direct main" 278 270 description: 279 271 name: connectivity_plus 280 - sha256: b8fe52979ff12432ecf8f0abf6ff70410b1bb734be1c9e4f2f86807ad7166c79 272 + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" 281 273 url: "https://pub.dev" 282 274 source: hosted 283 - version: "7.1.0" 275 + version: "7.1.1" 284 276 connectivity_plus_platform_interface: 285 277 dependency: transitive 286 278 description: ··· 612 604 dependency: "direct main" 613 605 description: 614 606 name: google_fonts 615 - sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e 607 + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" 616 608 url: "https://pub.dev" 617 609 source: hosted 618 - version: "8.0.2" 610 + version: "8.1.0" 619 611 graphs: 620 612 dependency: transitive 621 613 description: ··· 636 628 dependency: transitive 637 629 description: 638 630 name: hooks 639 - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 631 + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" 640 632 url: "https://pub.dev" 641 633 source: hosted 642 - version: "1.0.2" 634 + version: "1.0.3" 643 635 html: 644 636 dependency: transitive 645 637 description: ··· 876 868 dependency: "direct dev" 877 869 description: 878 870 name: mocktail 879 - sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" 871 + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" 880 872 url: "https://pub.dev" 881 873 source: hosted 882 - version: "1.0.4" 874 + version: "1.0.5" 883 875 multiformats: 884 876 dependency: transitive 885 877 description: ··· 1012 1004 dependency: transitive 1013 1005 description: 1014 1006 name: path_provider_android 1015 - sha256: "914a07484c4380e572998d30486e77e0d9cd2faec72fee268086d07bf7f302c9" 1007 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" 1016 1008 url: "https://pub.dev" 1017 1009 source: hosted 1018 - version: "2.3.0" 1010 + version: "2.3.1" 1019 1011 path_provider_foundation: 1020 1012 dependency: transitive 1021 1013 description: ··· 1192 1184 url: "https://pub.dev" 1193 1185 source: hosted 1194 1186 version: "4.1.0" 1187 + record_use: 1188 + dependency: transitive 1189 + description: 1190 + name: record_use 1191 + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" 1192 + url: "https://pub.dev" 1193 + source: hosted 1194 + version: "0.6.0" 1195 1195 share_plus: 1196 1196 dependency: "direct main" 1197 1197 description: ··· 1545 1545 dependency: transitive 1546 1546 description: 1547 1547 name: vm_service 1548 - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" 1548 + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" 1549 1549 url: "https://pub.dev" 1550 1550 source: hosted 1551 - version: "15.0.2" 1551 + version: "15.2.0" 1552 1552 wakelock_plus: 1553 1553 dependency: transitive 1554 1554 description: 1555 1555 name: wakelock_plus 1556 - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" 1556 + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 1557 1557 url: "https://pub.dev" 1558 1558 source: hosted 1559 - version: "1.5.1" 1559 + version: "1.5.2" 1560 1560 wakelock_plus_platform_interface: 1561 1561 dependency: transitive 1562 1562 description: 1563 1563 name: wakelock_plus_platform_interface 1564 - sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" 1564 + sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc" 1565 1565 url: "https://pub.dev" 1566 1566 source: hosted 1567 - version: "1.4.0" 1567 + version: "1.5.0" 1568 1568 watcher: 1569 1569 dependency: transitive 1570 1570 description: ··· 1654 1654 source: hosted 1655 1655 version: "3.1.3" 1656 1656 sdks: 1657 - dart: ">=3.10.3 <4.0.0" 1658 - flutter: ">=3.38.4" 1657 + dart: ">=3.11.0 <4.0.0" 1658 + flutter: ">=3.41.0"
+140
test/features/feed/presentation/post_card_with_actions_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_defs.dart'; 6 + import 'package:bloc_test/bloc_test.dart'; 7 + import 'package:flutter/material.dart'; 8 + import 'package:flutter_bloc/flutter_bloc.dart'; 9 + import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 12 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 13 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 14 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 15 + import 'package:mocktail/mocktail.dart'; 16 + 17 + class MockPostActionRepository extends Mock implements PostActionRepository {} 18 + 19 + class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 20 + 21 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 22 + 23 + FeedViewPost _makePostView() { 24 + return FeedViewPost( 25 + post: PostView( 26 + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc123'), 27 + cid: 'cid-abc123', 28 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 29 + record: { 30 + r'$type': 'app.bsky.feed.post', 31 + 'text': 'Hello world', 32 + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), 33 + }, 34 + indexedAt: DateTime.utc(2026, 3, 15), 35 + ), 36 + ); 37 + } 38 + 39 + void main() { 40 + setUpAll(() { 41 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.feed.post/fallback')); 42 + }); 43 + 44 + late MockPostActionRepository postActionRepository; 45 + late MockSavedPostsCubit savedPostsCubit; 46 + late MockConnectivityCubit connectivityCubit; 47 + 48 + setUp(() { 49 + postActionRepository = MockPostActionRepository(); 50 + savedPostsCubit = MockSavedPostsCubit(); 51 + connectivityCubit = MockConnectivityCubit(); 52 + 53 + when(() => savedPostsCubit.state).thenReturn(const SavedPostsState()); 54 + whenListen(savedPostsCubit, const Stream<SavedPostsState>.empty(), initialState: const SavedPostsState()); 55 + 56 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 57 + whenListen( 58 + connectivityCubit, 59 + const Stream<ConnectivityState>.empty(), 60 + initialState: const ConnectivityState.online(), 61 + ); 62 + }); 63 + 64 + testWidgets('keeps optimistic like loading state across parent rebuilds', (tester) async { 65 + final likeCompleter = Completer<String>(); 66 + when( 67 + () => postActionRepository.likePost( 68 + uri: any(named: 'uri'), 69 + cid: any(named: 'cid'), 70 + ), 71 + ).thenAnswer((_) => likeCompleter.future); 72 + 73 + await tester.pumpWidget( 74 + MultiRepositoryProvider( 75 + providers: [ 76 + RepositoryProvider<PostActionRepository>.value(value: postActionRepository), 77 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 78 + ], 79 + child: MultiBlocProvider( 80 + providers: [ 81 + BlocProvider<SavedPostsCubit>.value(value: savedPostsCubit), 82 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 83 + ], 84 + child: const MaterialApp(home: _RebuildHarness()), 85 + ), 86 + ), 87 + ); 88 + 89 + await tester.pumpAndSettle(); 90 + 91 + await tester.tap(find.byIcon(Icons.favorite_outline)); 92 + await tester.pump(); 93 + 94 + verify( 95 + () => postActionRepository.likePost( 96 + uri: any(named: 'uri'), 97 + cid: any(named: 'cid'), 98 + ), 99 + ).called(1); 100 + 101 + await tester.tap(find.byKey(const ValueKey('rebuild-parent'))); 102 + await tester.pump(); 103 + 104 + expect(find.byIcon(Icons.favorite_outline), findsNothing); 105 + 106 + likeCompleter.complete('at://did:plc:author/app.bsky.feed.like/like123'); 107 + await tester.pumpAndSettle(); 108 + 109 + expect(find.byIcon(Icons.favorite), findsOneWidget); 110 + }); 111 + } 112 + 113 + class _RebuildHarness extends StatefulWidget { 114 + const _RebuildHarness(); 115 + 116 + @override 117 + State<_RebuildHarness> createState() => _RebuildHarnessState(); 118 + } 119 + 120 + class _RebuildHarnessState extends State<_RebuildHarness> { 121 + final FeedViewPost _feedViewPost = _makePostView(); 122 + 123 + @override 124 + Widget build(BuildContext context) { 125 + return Scaffold( 126 + body: Column( 127 + children: [ 128 + TextButton( 129 + key: const ValueKey('rebuild-parent'), 130 + onPressed: () => setState(() {}), 131 + child: const Text('Rebuild Parent'), 132 + ), 133 + Expanded( 134 + child: PostCardWithActions(feedViewPost: _feedViewPost, accountDid: 'did:plc:me'), 135 + ), 136 + ], 137 + ), 138 + ); 139 + } 140 + }
+22
test/features/typeahead/presentation/typeahead_text_field_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:flutter_test/flutter_test.dart'; 3 5 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; ··· 93 95 await tester.pumpAndSettle(); 94 96 95 97 expect(queryCount, 0); 98 + }); 99 + 100 + testWidgets('shows and hides input loading spinner while fetching suggestions', (tester) async { 101 + final controller = TextEditingController(); 102 + final completer = Completer<List<TypeaheadResult>>(); 103 + final repository = _FakeTypeaheadRepository( 104 + searchHandler: ({required String query, int limit = 10}) => completer.future, 105 + ); 106 + 107 + await tester.pumpWidget(_buildSubject(controller: controller, repository: repository, onSelected: (_) {})); 108 + 109 + await tester.enterText(find.byType(TextFormField), 'alice'); 110 + await tester.pump(const Duration(milliseconds: 5)); 111 + 112 + expect(find.byKey(const ValueKey('typeahead-input-loading-spinner')), findsOneWidget); 113 + 114 + completer.complete(const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social')]); 115 + await tester.pumpAndSettle(); 116 + 117 + expect(find.byKey(const ValueKey('typeahead-input-loading-spinner')), findsNothing); 96 118 }); 97 119 }); 98 120 }