[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

feat: known followers

+512 -39
+118
lib/src/core/design_system/templates/profile_page_template.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:skeletonizer/skeletonizer.dart'; 3 + import 'package:spark/src/core/design_system/components/atoms/avatar_stack.dart'; 3 4 import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; 4 5 import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 5 6 import 'package:spark/src/core/design_system/components/molecules/profile_action_buttons.dart'; ··· 8 9 import 'package:spark/src/core/design_system/components/molecules/profile_stats.dart'; 9 10 import 'package:spark/src/core/design_system/components/organisms/sticky_profile_tab_bar.dart'; 10 11 import 'package:spark/src/core/design_system/tokens/typography.dart'; 12 + import 'package:spark/src/core/l10n/app_localizations.dart'; 13 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 11 14 12 15 class ProfilePageTemplate extends StatelessWidget { 13 16 const ProfilePageTemplate({ ··· 23 26 this.avatarUrl, 24 27 this.description, 25 28 this.links, 29 + this.knownFollowers, 26 30 this.hasStories = false, 27 31 this.isFollowing = false, 28 32 this.isBlocking = false, ··· 35 39 this.onUnfollowTap, 36 40 this.onUnblockTap, 37 41 this.onEarlySupporterTap, 42 + this.onKnownFollowersTap, 38 43 this.onMentionTap, 39 44 this.onAddStoryTap, 40 45 this.appBarTitle, ··· 56 61 final String? avatarUrl; 57 62 final String? description; 58 63 final List<String>? links; 64 + final KnownFollowers? knownFollowers; 59 65 final bool hasStories; 60 66 final bool isCurrentUser; 61 67 final bool isFollowing; ··· 69 75 final VoidCallback? onUnfollowTap; 70 76 final VoidCallback? onUnblockTap; 71 77 final VoidCallback? onEarlySupporterTap; 78 + final VoidCallback? onKnownFollowersTap; 72 79 final Function(String username)? onMentionTap; 73 80 final VoidCallback? onAddStoryTap; 74 81 final String? appBarTitle; ··· 116 123 avatarUrl: avatarUrl, 117 124 description: description, 118 125 links: links, 126 + knownFollowers: knownFollowers, 119 127 hasStories: hasStories, 120 128 isCurrentUser: isCurrentUser, 121 129 isFollowing: isFollowing, ··· 129 137 onUnfollowTap: onUnfollowTap, 130 138 onUnblockTap: onUnblockTap, 131 139 onEarlySupporterTap: onEarlySupporterTap, 140 + onKnownFollowersTap: onKnownFollowersTap, 132 141 onMentionTap: onMentionTap, 133 142 onAddStoryTap: onAddStoryTap, 134 143 ), ··· 165 174 this.avatarUrl, 166 175 this.description, 167 176 this.links, 177 + this.knownFollowers, 168 178 this.onAvatarTap, 169 179 this.onFollowersTap, 170 180 this.onFollowingTap, ··· 173 183 this.onUnfollowTap, 174 184 this.onUnblockTap, 175 185 this.onEarlySupporterTap, 186 + this.onKnownFollowersTap, 176 187 this.onMentionTap, 177 188 this.onAddStoryTap, 178 189 }); ··· 185 196 final String? avatarUrl; 186 197 final String? description; 187 198 final List<String>? links; 199 + final KnownFollowers? knownFollowers; 188 200 final bool hasStories; 189 201 final bool isCurrentUser; 190 202 final bool isFollowing; ··· 198 210 final VoidCallback? onUnfollowTap; 199 211 final VoidCallback? onUnblockTap; 200 212 final VoidCallback? onEarlySupporterTap; 213 + final VoidCallback? onKnownFollowersTap; 201 214 final Function(String username)? onMentionTap; 202 215 final VoidCallback? onAddStoryTap; 203 216 ··· 270 283 onMentionTap: onMentionTap, 271 284 ), 272 285 ], 286 + KnownFollowersSummary( 287 + knownFollowers: knownFollowers, 288 + onTap: onKnownFollowersTap, 289 + ), 273 290 const SizedBox(height: 16), 274 291 Skeleton.leaf( 275 292 child: ProfileActionButtons( ··· 287 304 ); 288 305 } 289 306 } 307 + 308 + class KnownFollowersSummary extends StatelessWidget { 309 + const KnownFollowersSummary({ 310 + required this.knownFollowers, 311 + super.key, 312 + this.onTap, 313 + }); 314 + 315 + final KnownFollowers? knownFollowers; 316 + final VoidCallback? onTap; 317 + 318 + @override 319 + Widget build(BuildContext context) { 320 + final knownFollowers = this.knownFollowers; 321 + if (knownFollowers == null || 322 + knownFollowers.count <= 0 || 323 + knownFollowers.followers.isEmpty) { 324 + return const SizedBox.shrink(); 325 + } 326 + 327 + final l10n = AppLocalizations.of(context); 328 + final followers = knownFollowers.followers; 329 + final visibleFollowers = followers.take(2).toList(); 330 + final othersCount = knownFollowers.count - visibleFollowers.length; 331 + final text = _summaryText(l10n, visibleFollowers, othersCount); 332 + final theme = Theme.of(context); 333 + 334 + return Padding( 335 + padding: const EdgeInsets.only(top: 12), 336 + child: Semantics( 337 + button: onTap != null, 338 + child: InkWell( 339 + onTap: onTap, 340 + borderRadius: BorderRadius.circular(8), 341 + child: Padding( 342 + padding: const EdgeInsets.symmetric(vertical: 4), 343 + child: Row( 344 + children: [ 345 + AvatarStack( 346 + avatars: followers 347 + .map( 348 + (follower) => AvatarData( 349 + imageUrl: follower.avatar?.toString() ?? '', 350 + username: follower.displayName ?? follower.handle, 351 + ), 352 + ) 353 + .toList(), 354 + largeAvatarCount: 3, 355 + largeSize: 24, 356 + largeOverlap: 8, 357 + ), 358 + const SizedBox(width: 8), 359 + Expanded( 360 + child: Text( 361 + text, 362 + overflow: TextOverflow.ellipsis, 363 + style: AppTypography.textSmallMedium.copyWith( 364 + color: theme.colorScheme.onSurfaceVariant, 365 + ), 366 + ), 367 + ), 368 + ], 369 + ), 370 + ), 371 + ), 372 + ), 373 + ); 374 + } 375 + 376 + String _summaryText( 377 + AppLocalizations l10n, 378 + List<ProfileViewBasic> followers, 379 + int othersCount, 380 + ) { 381 + final firstName = _displayName(followers.first); 382 + if (followers.length == 1) { 383 + if (othersCount > 0) { 384 + return l10n.profileKnownFollowersOneAndOthers(firstName, othersCount); 385 + } 386 + return l10n.profileKnownFollowersOne(firstName); 387 + } 388 + 389 + final secondName = _displayName(followers[1]); 390 + if (othersCount > 0) { 391 + return l10n.profileKnownFollowersTwoAndOthers( 392 + firstName, 393 + secondName, 394 + othersCount, 395 + ); 396 + } 397 + return l10n.profileKnownFollowersTwo(firstName, secondName); 398 + } 399 + 400 + String _displayName(ProfileViewBasic profile) { 401 + final displayName = profile.displayName; 402 + if (displayName != null && displayName.trim().isNotEmpty) { 403 + return displayName; 404 + } 405 + return profile.handle; 406 + } 407 + }
+34
lib/src/core/l10n/app_localizations.dart
··· 874 874 /// **'Followers'** 875 875 String get pageTitleFollowers; 876 876 877 + /// Known followers page title 878 + /// 879 + /// In en, this message translates to: 880 + /// **'Known followers'** 881 + String get pageTitleKnownFollowers; 882 + 877 883 /// Following page title 878 884 /// 879 885 /// In en, this message translates to: ··· 1335 1341 /// In en, this message translates to: 1336 1342 /// **'Sending...'** 1337 1343 String get messageSending; 1344 + 1345 + /// Profile social context when one known follower is displayed 1346 + /// 1347 + /// In en, this message translates to: 1348 + /// **'Followed by {name}'** 1349 + String profileKnownFollowersOne(String name); 1350 + 1351 + /// Profile social context when two known followers are displayed 1352 + /// 1353 + /// In en, this message translates to: 1354 + /// **'Followed by {firstName} and {secondName}'** 1355 + String profileKnownFollowersTwo(String firstName, String secondName); 1356 + 1357 + /// Profile social context when one known follower is displayed plus additional known followers 1358 + /// 1359 + /// In en, this message translates to: 1360 + /// **'Followed by {name} and {count, plural, =1{1 other} other{{count} others}}'** 1361 + String profileKnownFollowersOneAndOthers(String name, int count); 1362 + 1363 + /// Profile social context when two known followers are displayed plus additional known followers 1364 + /// 1365 + /// In en, this message translates to: 1366 + /// **'Followed by {firstName}, {secondName}, and {count, plural, =1{1 other} other{{count} others}}'** 1367 + String profileKnownFollowersTwoAndOthers( 1368 + String firstName, 1369 + String secondName, 1370 + int count, 1371 + ); 1338 1372 1339 1373 /// Send button text 1340 1374 ///
+39
lib/src/core/l10n/app_localizations_en.dart
··· 421 421 String get pageTitleFollowers => 'Followers'; 422 422 423 423 @override 424 + String get pageTitleKnownFollowers => 'Known followers'; 425 + 426 + @override 424 427 String get pageTitleFollowing => 'Following'; 425 428 426 429 @override ··· 673 676 674 677 @override 675 678 String get messageSending => 'Sending...'; 679 + 680 + @override 681 + String profileKnownFollowersOne(String name) { 682 + return 'Followed by $name'; 683 + } 684 + 685 + @override 686 + String profileKnownFollowersTwo(String firstName, String secondName) { 687 + return 'Followed by $firstName and $secondName'; 688 + } 689 + 690 + @override 691 + String profileKnownFollowersOneAndOthers(String name, int count) { 692 + String _temp0 = intl.Intl.pluralLogic( 693 + count, 694 + locale: localeName, 695 + other: '$count others', 696 + one: '1 other', 697 + ); 698 + return 'Followed by $name and $_temp0'; 699 + } 700 + 701 + @override 702 + String profileKnownFollowersTwoAndOthers( 703 + String firstName, 704 + String secondName, 705 + int count, 706 + ) { 707 + String _temp0 = intl.Intl.pluralLogic( 708 + count, 709 + locale: localeName, 710 + other: '$count others', 711 + one: '1 other', 712 + ); 713 + return 'Followed by $firstName, $secondName, and $_temp0'; 714 + } 676 715 677 716 @override 678 717 String get buttonSend => 'Send';
+57
lib/src/core/l10n/intl_en.arb
··· 687 687 "description": "Followers page title" 688 688 }, 689 689 690 + "pageTitleKnownFollowers": "Known followers", 691 + "@pageTitleKnownFollowers": { 692 + "description": "Known followers page title" 693 + }, 694 + 690 695 "pageTitleFollowing": "Following", 691 696 "@pageTitleFollowing": { 692 697 "description": "Following page title" ··· 1108 1113 "messageSending": "Sending...", 1109 1114 "@messageSending": { 1110 1115 "description": "Sending message progress indicator" 1116 + }, 1117 + 1118 + "profileKnownFollowersOne": "Followed by {name}", 1119 + "@profileKnownFollowersOne": { 1120 + "description": "Profile social context when one known follower is displayed", 1121 + "placeholders": { 1122 + "name": { 1123 + "type": "String" 1124 + } 1125 + } 1126 + }, 1127 + 1128 + "profileKnownFollowersTwo": "Followed by {firstName} and {secondName}", 1129 + "@profileKnownFollowersTwo": { 1130 + "description": "Profile social context when two known followers are displayed", 1131 + "placeholders": { 1132 + "firstName": { 1133 + "type": "String" 1134 + }, 1135 + "secondName": { 1136 + "type": "String" 1137 + } 1138 + } 1139 + }, 1140 + 1141 + "profileKnownFollowersOneAndOthers": "Followed by {name} and {count, plural, =1{1 other} other{{count} others}}", 1142 + "@profileKnownFollowersOneAndOthers": { 1143 + "description": "Profile social context when one known follower is displayed plus additional known followers", 1144 + "placeholders": { 1145 + "name": { 1146 + "type": "String" 1147 + }, 1148 + "count": { 1149 + "type": "int" 1150 + } 1151 + } 1152 + }, 1153 + 1154 + "profileKnownFollowersTwoAndOthers": "Followed by {firstName}, {secondName}, and {count, plural, =1{1 other} other{{count} others}}", 1155 + "@profileKnownFollowersTwoAndOthers": { 1156 + "description": "Profile social context when two known followers are displayed plus additional known followers", 1157 + "placeholders": { 1158 + "firstName": { 1159 + "type": "String" 1160 + }, 1161 + "secondName": { 1162 + "type": "String" 1163 + }, 1164 + "count": { 1165 + "type": "int" 1166 + } 1167 + } 1111 1168 }, 1112 1169 1113 1170 "buttonSend": "Send",
+4 -13
lib/src/core/network/atproto/data/models/actor_models.dart
··· 18 18 // blocked by list: when we add lists add this field 19 19 @AtUriConverter() AtUri? following, 20 20 @AtUriConverter() AtUri? followedBy, 21 - KnownFollowers? followers, 21 + KnownFollowers? knownFollowers, 22 22 }) = _ActorViewer; 23 23 const ActorViewer._(); 24 24 ··· 31 31 @JsonSerializable(explicitToJson: true) 32 32 const factory KnownFollowers({ 33 33 required int count, 34 - required List<String> followersDids, // to avoid circular dependency 34 + required List<ProfileViewBasic> followers, 35 35 }) = _KnownFollowers; 36 36 const KnownFollowers._(); 37 37 38 - factory KnownFollowers.fromJson(Map<String, dynamic> json) => switch (json) { 39 - { 40 - 'followers': final List<ProfileViewBasic> profiles, 41 - 'count': final int count, 42 - } => 43 - _$KnownFollowersFromJson({ 44 - 'count': count, 45 - 'followersDids': profiles.map((e) => e.did).toList(), 46 - }), 47 - _ => _$KnownFollowersFromJson(json), 48 - }; 38 + factory KnownFollowers.fromJson(Map<String, dynamic> json) => 39 + _$KnownFollowersFromJson(json); 49 40 } 50 41 51 42 @freezed
+6
lib/src/core/network/atproto/data/repositories/graph_repository.dart
··· 9 9 /// [cursor] Optional cursor for pagination 10 10 Future<FollowersResponse> getFollowers(String did, {String? cursor}); 11 11 12 + /// Get followers for a DID who are followed by the viewer 13 + /// 14 + /// [did] The DID to get known followers for 15 + /// [cursor] Optional cursor for pagination 16 + Future<FollowersResponse> getKnownFollowers(String did, {String? cursor}); 17 + 12 18 /// Get follows for a DID 13 19 /// 14 20 /// [did] The DID to get follows for
+40
lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart
··· 56 56 } 57 57 58 58 @override 59 + Future<FollowersResponse> getKnownFollowers( 60 + String did, { 61 + String? cursor, 62 + }) async { 63 + _logger.d('Getting known followers for DID: $did with cursor: $cursor'); 64 + return _client.executeWithRetry(() async { 65 + if (!_client.authRepository.isAuthenticated) { 66 + _logger.w('Not authenticated'); 67 + throw Exception('Not authenticated'); 68 + } 69 + 70 + final atproto = _client.authRepository.atproto; 71 + if (atproto == null) { 72 + _logger.e('AtProto not initialized'); 73 + throw Exception('AtProto not initialized'); 74 + } 75 + try { 76 + final params = <String, dynamic>{'actor': did}; 77 + if (cursor != null) { 78 + params['cursor'] = cursor; 79 + } 80 + final result = await atproto.get( 81 + NSID.parse('so.sprk.graph.getKnownFollowers'), 82 + parameters: params, 83 + headers: {'atproto-proxy': _client.sprkDid}, 84 + to: (jsonMap) => jsonMap, 85 + adaptor: (uint8) => 86 + jsonDecode(utf8.decode(uint8 as List<int>)) 87 + as Map<String, dynamic>, 88 + ); 89 + _logger.d('Known followers retrieved successfully'); 90 + return FollowersResponse.fromJson(result.data as Map<String, dynamic>); 91 + } on FormatException catch (fe) { 92 + _logger.e('Error retrieving known followers for DID: $did', error: fe); 93 + throw Exception('Failed to retrieve known followers for DID: $did'); 94 + } 95 + }); 96 + } 97 + 98 + @override 59 99 Future<FollowsResponse> getFollows(String did, {String? cursor}) async { 60 100 _logger.d('Getting follows for DID: $did with cursor: $cursor'); 61 101 return _client.executeWithRetry(() async {
+35 -22
lib/src/features/profile/providers/user_list_provider.dart
··· 57 57 List<ProfileView> profiles; 58 58 String? cursor; 59 59 60 - if (type == UserListType.followers) { 61 - final response = await _graphRepository.getFollowers(did); 62 - profiles = response.followers.toList(); 63 - cursor = response.cursor; 64 - } else { 65 - final response = await _graphRepository.getFollows(did); 66 - profiles = response.follows.toList(); 67 - cursor = response.cursor; 60 + switch (type) { 61 + case UserListType.followers: 62 + final response = await _graphRepository.getFollowers(did); 63 + profiles = response.followers.toList(); 64 + cursor = response.cursor; 65 + case UserListType.following: 66 + final response = await _graphRepository.getFollows(did); 67 + profiles = response.follows.toList(); 68 + cursor = response.cursor; 69 + case UserListType.knownFollowers: 70 + final response = await _graphRepository.getKnownFollowers(did); 71 + profiles = response.followers.toList(); 72 + cursor = response.cursor; 68 73 } 69 74 70 75 await _fetchAndMergeProfiles(profiles); ··· 186 191 List<ProfileView> newProfiles; 187 192 String? newCursor; 188 193 189 - if (type == UserListType.followers) { 190 - final response = await _graphRepository.getFollowers( 191 - did, 192 - cursor: state.value!.cursor, 193 - ); 194 - newProfiles = response.followers.toList(); 195 - newCursor = response.cursor; 196 - } else { 197 - final response = await _graphRepository.getFollows( 198 - did, 199 - cursor: state.value!.cursor, 200 - ); 201 - newProfiles = response.follows.toList(); 202 - newCursor = response.cursor; 194 + switch (type) { 195 + case UserListType.followers: 196 + final response = await _graphRepository.getFollowers( 197 + did, 198 + cursor: state.value!.cursor, 199 + ); 200 + newProfiles = response.followers.toList(); 201 + newCursor = response.cursor; 202 + case UserListType.following: 203 + final response = await _graphRepository.getFollows( 204 + did, 205 + cursor: state.value!.cursor, 206 + ); 207 + newProfiles = response.follows.toList(); 208 + newCursor = response.cursor; 209 + case UserListType.knownFollowers: 210 + final response = await _graphRepository.getKnownFollowers( 211 + did, 212 + cursor: state.value!.cursor, 213 + ); 214 + newProfiles = response.followers.toList(); 215 + newCursor = response.cursor; 203 216 } 204 217 205 218 await _fetchAndMergeProfiles(newProfiles);
+4
lib/src/features/profile/ui/pages/profile_page.dart
··· 280 280 avatarUrl: profile.avatar?.toString(), 281 281 description: description.isNotEmpty ? description : null, 282 282 links: uniqueLinks.isNotEmpty ? uniqueLinks : null, 283 + knownFollowers: profile.viewer?.knownFollowers, 284 + onKnownFollowersTap: () => context.router.push( 285 + UserListRoute(did: widget.did, type: UserListType.knownFollowers), 286 + ), 283 287 hasStories: profile.stories?.isNotEmpty ?? false, 284 288 isCurrentUser: isCurrentUser, 285 289 isFollowing: profile.viewer?.following != null,
+6 -4
lib/src/features/profile/ui/pages/user_list_page.dart
··· 6 6 import 'package:spark/src/features/profile/providers/user_list_provider.dart'; 7 7 import 'package:spark/src/features/profile/ui/widgets/user_list_view.dart'; 8 8 9 - enum UserListType { followers, following } 9 + enum UserListType { followers, following, knownFollowers } 10 10 11 11 @RoutePage() 12 12 class UserListPage extends ConsumerStatefulWidget { ··· 51 51 userListProvider(did: widget.did, type: widget.type), 52 52 ); 53 53 final l10n = AppLocalizations.of(context); 54 - final title = widget.type == UserListType.followers 55 - ? l10n.pageTitleFollowers 56 - : l10n.pageTitleFollowing; 54 + final title = switch (widget.type) { 55 + UserListType.followers => l10n.pageTitleFollowers, 56 + UserListType.following => l10n.pageTitleFollowing, 57 + UserListType.knownFollowers => l10n.pageTitleKnownFollowers, 58 + }; 57 59 58 60 return Scaffold( 59 61 appBar: AppBar(
+129
test/src/core/design_system/templates/profile_page_template_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:spark/src/core/design_system/templates/profile_page_template.dart'; 4 + import 'package:spark/src/core/l10n/app_localizations.dart'; 5 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 6 + 7 + void main() { 8 + group('KnownFollowersSummary', () { 9 + testWidgets('hides when known followers are null', (tester) async { 10 + await tester.pumpWidget( 11 + _TestApp(child: KnownFollowersSummary(knownFollowers: null)), 12 + ); 13 + 14 + expect(find.textContaining('Followed by'), findsNothing); 15 + }); 16 + 17 + testWidgets('hides when count is zero', (tester) async { 18 + await tester.pumpWidget( 19 + _TestApp( 20 + child: KnownFollowersSummary( 21 + knownFollowers: const KnownFollowers(count: 0, followers: []), 22 + ), 23 + ), 24 + ); 25 + 26 + expect(find.textContaining('Followed by'), findsNothing); 27 + }); 28 + 29 + testWidgets('hides when follower list is empty', (tester) async { 30 + await tester.pumpWidget( 31 + _TestApp( 32 + child: KnownFollowersSummary( 33 + knownFollowers: const KnownFollowers(count: 3, followers: []), 34 + ), 35 + ), 36 + ); 37 + 38 + expect(find.textContaining('Followed by'), findsNothing); 39 + }); 40 + 41 + testWidgets('shows one known follower', (tester) async { 42 + await tester.pumpWidget( 43 + _TestApp( 44 + child: KnownFollowersSummary( 45 + knownFollowers: const KnownFollowers( 46 + count: 1, 47 + followers: [ 48 + ProfileViewBasic( 49 + did: 'did:plc:alice', 50 + handle: 'alice.sprk.so', 51 + displayName: 'Alice', 52 + ), 53 + ], 54 + ), 55 + ), 56 + ), 57 + ); 58 + 59 + expect(find.text('Followed by Alice'), findsOneWidget); 60 + }); 61 + 62 + testWidgets('uses total count for others copy', (tester) async { 63 + await tester.pumpWidget( 64 + _TestApp( 65 + child: KnownFollowersSummary( 66 + knownFollowers: const KnownFollowers( 67 + count: 5, 68 + followers: [ 69 + ProfileViewBasic( 70 + did: 'did:plc:alice', 71 + handle: 'alice.sprk.so', 72 + displayName: 'Alice', 73 + ), 74 + ProfileViewBasic( 75 + did: 'did:plc:bob', 76 + handle: 'bob.sprk.so', 77 + displayName: 'Bob', 78 + ), 79 + ], 80 + ), 81 + ), 82 + ), 83 + ); 84 + 85 + expect(find.text('Followed by Alice, Bob, and 3 others'), findsOneWidget); 86 + }); 87 + 88 + testWidgets('calls onTap when tapped', (tester) async { 89 + var tapped = false; 90 + 91 + await tester.pumpWidget( 92 + _TestApp( 93 + child: KnownFollowersSummary( 94 + knownFollowers: const KnownFollowers( 95 + count: 1, 96 + followers: [ 97 + ProfileViewBasic( 98 + did: 'did:plc:alice', 99 + handle: 'alice.sprk.so', 100 + displayName: 'Alice', 101 + ), 102 + ], 103 + ), 104 + onTap: () => tapped = true, 105 + ), 106 + ), 107 + ); 108 + 109 + await tester.tap(find.text('Followed by Alice')); 110 + 111 + expect(tapped, isTrue); 112 + }); 113 + }); 114 + } 115 + 116 + class _TestApp extends StatelessWidget { 117 + const _TestApp({required this.child}); 118 + 119 + final Widget child; 120 + 121 + @override 122 + Widget build(BuildContext context) { 123 + return MaterialApp( 124 + localizationsDelegates: AppLocalizations.localizationsDelegates, 125 + supportedLocales: AppLocalizations.supportedLocales, 126 + home: Scaffold(body: child), 127 + ); 128 + } 129 + }
+40
test/src/core/network/atproto/data/models/actor_models_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 3 + 4 + void main() { 5 + group('ProfileViewDetailed', () { 6 + test('parses viewer known followers with nested profile basics', () { 7 + final profile = ProfileViewDetailed.fromJson({ 8 + 'did': 'did:plc:profile', 9 + 'handle': 'profile.sprk.so', 10 + 'viewer': { 11 + 'following': 'at://did:plc:viewer/app.bsky.graph.follow/123', 12 + 'knownFollowers': { 13 + 'count': 2, 14 + 'followers': [ 15 + { 16 + 'did': 'did:plc:alice', 17 + 'handle': 'alice.sprk.so', 18 + 'displayName': 'Alice', 19 + 'avatar': 'https://cdn.example.com/alice.jpg', 20 + }, 21 + {'did': 'did:plc:bob', 'handle': 'bob.sprk.so'}, 22 + ], 23 + }, 24 + }, 25 + }); 26 + 27 + final knownFollowers = profile.viewer?.knownFollowers; 28 + expect(knownFollowers, isNotNull); 29 + expect(knownFollowers!.count, 2); 30 + expect(knownFollowers.followers, hasLength(2)); 31 + expect(knownFollowers.followers.first.did, 'did:plc:alice'); 32 + expect(knownFollowers.followers.first.displayName, 'Alice'); 33 + expect( 34 + knownFollowers.followers.first.avatar?.toString(), 35 + contains('alice.jpg'), 36 + ); 37 + expect(knownFollowers.followers.last.handle, 'bob.sprk.so'); 38 + }); 39 + }); 40 + }