[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.

following/followers list (#74)

* indo

* rm avatar widget

* paginate

* update

* uri thing

* sparky kkkkkkkkkkkkkkkkkk

authored by

Davi Rodrigues and committed by
GitHub
7f179840 4aec58c8

+377 -38
-1
lib/main.dart
··· 4 4 import 'package:flutter_dotenv/flutter_dotenv.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; 6 6 import 'package:fvp/fvp.dart' as fvp; 7 - 8 7 import 'package:sparksocial/src/core/di/service_locator.dart'; 9 8 import 'package:sparksocial/src/core/theme/data/models/app_theme.dart'; 10 9 import 'package:sparksocial/src/core/utils/logging/logging.dart';
+5 -4
lib/src/core/network/atproto/data/models/actor_models.dart
··· 1 1 import 'package:atproto/atproto.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 3 import 'package:freezed_annotation/freezed_annotation.dart'; 4 + import 'package:sparksocial/src/core/utils/uri_converter.dart'; 4 5 5 6 part 'actor_models.freezed.dart'; 6 7 part 'actor_models.g.dart'; ··· 48 49 required String did, 49 50 required String handle, 50 51 String? displayName, 51 - @AtUriConverter() AtUri? avatar, 52 + @UriConverter() Uri? avatar, 52 53 // associated: lists, feedgens, starterpacks, labelers, chat?? not needed for now 53 54 ActorViewer? viewer, 54 55 List<StrongRef>? stories, ··· 66 67 required String handle, 67 68 String? displayName, 68 69 String? description, 69 - @AtUriConverter() AtUri? avatar, 70 + @UriConverter() Uri? avatar, 70 71 // associated: lists, feedgens, starterpacks, labelers, chat?? not needed for now 71 72 // indexedAt and createdAt 72 73 ActorViewer? viewer, ··· 109 110 required String handle, 110 111 String? displayName, 111 112 String? description, 112 - @AtUriConverter() AtUri? avatar, 113 - @AtUriConverter() AtUri? banner, 113 + @UriConverter() Uri? avatar, 114 + @UriConverter() Uri? banner, 114 115 int? followersCount, 115 116 int? followsCount, 116 117 int? postsCount,
+4 -2
lib/src/core/network/atproto/data/repositories/graph_repository.dart
··· 6 6 /// Get followers for a DID 7 7 /// 8 8 /// [did] The DID to get followers for 9 - Future<FollowersResponse> getFollowers(String did); 9 + /// [cursor] Optional cursor for pagination 10 + Future<FollowersResponse> getFollowers(String did, {String? cursor}); 10 11 11 12 /// Get follows for a DID 12 13 /// 13 14 /// [did] The DID to get follows for 14 - Future<FollowsResponse> getFollows(String did); 15 + /// [cursor] Optional cursor for pagination 16 + Future<FollowsResponse> getFollows(String did, {String? cursor}); 15 17 16 18 /// Follow a user 17 19 ///
+40 -24
lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart
··· 20 20 final SparkLogger _logger = GetIt.instance<LogService>().getLogger('GraphRepository'); 21 21 22 22 @override 23 - Future<FollowersResponse> getFollowers(String did) async { 24 - _logger.d('Getting followers for DID: $did'); 23 + Future<FollowersResponse> getFollowers(String did, {String? cursor}) async { 24 + _logger.d('Getting followers for DID: $did with cursor: $cursor'); 25 25 return _client.executeWithRetry(() async { 26 26 if (!_client.authRepository.isAuthenticated) { 27 27 _logger.w('Not authenticated'); ··· 33 33 _logger.e('AtProto not initialized'); 34 34 throw Exception('AtProto not initialized'); 35 35 } 36 - 37 - final result = await atproto.get( 38 - NSID.parse('so.sprk.graph.getFollowers'), 39 - parameters: {'actor': did}, 40 - headers: {'atproto-proxy': _client.sprkDid}, 41 - to: (jsonMap) => jsonMap, 42 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 43 - ); 44 - _logger.d('Followers retrieved successfully'); 45 - return FollowersResponse.fromJson(result.data as Map<String, dynamic>); 36 + try { 37 + final params = <String, dynamic>{'actor': did}; 38 + if (cursor != null) { 39 + params['cursor'] = cursor; 40 + } 41 + final result = await atproto.get( 42 + NSID.parse('so.sprk.graph.getFollowers'), 43 + parameters: params, 44 + headers: {'atproto-proxy': _client.sprkDid}, 45 + to: (jsonMap) => jsonMap, 46 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 47 + ); 48 + _logger.d('Followers retrieved successfully'); 49 + return FollowersResponse.fromJson(result.data as Map<String, dynamic>); 50 + } on FormatException catch (fe) { 51 + _logger.e('Error retrieving followers for DID: $did', error: fe); 52 + throw Exception('Failed to retrieve followers for DID: $did'); 53 + } 46 54 }); 47 55 } 48 56 49 57 @override 50 - Future<FollowsResponse> getFollows(String did) async { 51 - _logger.d('Getting follows for DID: $did'); 58 + Future<FollowsResponse> getFollows(String did, {String? cursor}) async { 59 + _logger.d('Getting follows for DID: $did with cursor: $cursor'); 52 60 return _client.executeWithRetry(() async { 53 61 if (!_client.authRepository.isAuthenticated) { 54 62 _logger.w('Not authenticated'); ··· 60 68 _logger.e('AtProto not initialized'); 61 69 throw Exception('AtProto not initialized'); 62 70 } 63 - 64 - final result = await atproto.get( 65 - NSID.parse('so.sprk.graph.getFollows'), 66 - parameters: {'actor': did}, 67 - headers: {'atproto-proxy': _client.sprkDid}, 68 - to: (jsonMap) => jsonMap, 69 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 70 - ); 71 - _logger.d('Follows retrieved successfully'); 72 - return FollowsResponse.fromJson(result.data as Map<String, dynamic>); 71 + try { 72 + final params = <String, dynamic>{'actor': did}; 73 + if (cursor != null) { 74 + params['cursor'] = cursor; 75 + } 76 + final result = await atproto.get( 77 + NSID.parse('so.sprk.graph.getFollows'), 78 + parameters: params, 79 + headers: {'atproto-proxy': _client.sprkDid}, 80 + to: (jsonMap) => jsonMap, 81 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 82 + ); 83 + _logger.d('Follows retrieved successfully'); 84 + return FollowsResponse.fromJson(result.data as Map<String, dynamic>); 85 + } on FormatException catch (fe) { 86 + _logger.e('Error retrieving follows for DID: $did', error: fe); 87 + throw Exception('Failed to retrieve follows for DID: $did'); 88 + } 73 89 }); 74 90 } 75 91
+2
lib/src/core/routing/app_router.dart
··· 4 4 import 'package:image_picker/image_picker.dart'; 5 5 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 6 import 'package:sparksocial/src/core/routing/pages.dart'; 7 + import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 7 8 import 'package:video_player/video_player.dart'; 8 9 9 10 part 'app_router.gr.dart'; ··· 76 77 AutoRoute(page: ProfilePhotosRoute.page, path: 'photos'), 77 78 ], 78 79 ), 80 + AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), 79 81 AutoRoute(page: VideoPlaybackRoute.page, path: '/video-playback'), 80 82 AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), 81 83 AutoRoute(page: StoryReviewRoute.page, path: '/story-review'),
+11
lib/src/core/utils/uri_converter.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + class UriConverter implements JsonConverter<Uri, String> { 4 + const UriConverter(); 5 + 6 + @override 7 + Uri fromJson(String json) => Uri.parse(json); 8 + 9 + @override 10 + String toJson(Uri object) => object.toString(); 11 + }
+165
lib/src/features/profile/providers/user_list_provider.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/bluesky.dart'; 3 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 5 + import 'package:sparksocial/src/core/di/service_locator.dart'; 6 + import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 7 + import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 8 + 9 + part 'user_list_provider.g.dart'; 10 + 11 + class PaginatedUserList { 12 + final List<ProfileView> profiles; 13 + final String? cursor; 14 + final bool isFetchingMore; 15 + 16 + PaginatedUserList({required this.profiles, this.cursor, this.isFetchingMore = false}); 17 + 18 + PaginatedUserList copyWith({ 19 + List<ProfileView>? profiles, 20 + String? cursor, 21 + bool? isFetchingMore, 22 + bool updateCursor = false, 23 + }) { 24 + // remove profiles with unknown.invalid handle 25 + profiles?.removeWhere((profile) => profile.handle == 'unknown.invalid' || profile.handle.isEmpty); 26 + return PaginatedUserList( 27 + profiles: profiles ?? this.profiles, 28 + cursor: updateCursor ? cursor : this.cursor, 29 + isFetchingMore: isFetchingMore ?? this.isFetchingMore, 30 + ); 31 + } 32 + } 33 + 34 + @Riverpod(keepAlive: true) 35 + class UserList extends _$UserList { 36 + final GraphRepository _graphRepository = sl<GraphRepository>(); 37 + final AuthRepository _authRepository = sl<AuthRepository>(); 38 + 39 + @override 40 + Future<PaginatedUserList> build({required String did, required UserListType type}) async { 41 + List<ProfileView> profiles; 42 + String? cursor; 43 + 44 + if (type == UserListType.followers) { 45 + final response = await _graphRepository.getFollowers(did); 46 + profiles = response.followers.toList(); 47 + cursor = response.cursor; 48 + } else { 49 + final response = await _graphRepository.getFollows(did); 50 + profiles = response.follows.toList(); 51 + cursor = response.cursor; 52 + } 53 + 54 + await _fetchAndMergeProfilesFromBsky(profiles); 55 + 56 + // remove profiles with unknown.invalid handle 57 + profiles.removeWhere((profile) => profile.handle == 'unknown.invalid' || profile.handle.isEmpty); 58 + 59 + return PaginatedUserList(profiles: profiles, cursor: cursor); 60 + } 61 + 62 + Future<void> _fetchAndMergeProfilesFromBsky(List<ProfileView> profiles) async { 63 + final didsToFetch = profiles.where((profile) => profile.displayName == null).map((profile) => profile.did).toList(); 64 + 65 + if (didsToFetch.isNotEmpty) { 66 + final session = _authRepository.session; 67 + if (session != null) { 68 + final bsky = Bluesky.fromSession(session); 69 + final fetchedProfiles = <ActorProfile>[]; 70 + 71 + for (var i = 0; i < didsToFetch.length; i += 25) { 72 + final batch = didsToFetch.sublist(i, i + 25 > didsToFetch.length ? didsToFetch.length : i + 25); 73 + final profilesResponse = await bsky.actor.getProfiles(actors: batch); 74 + fetchedProfiles.addAll(profilesResponse.data.profiles); 75 + } 76 + final profilesMap = {for (final p in fetchedProfiles) p.did: p}; 77 + 78 + for (var i = 0; i < profiles.length; i++) { 79 + final profile = profiles[i]; 80 + if (profilesMap.containsKey(profile.did)) { 81 + final fetchedProfile = profilesMap[profile.did]!; 82 + profiles[i] = profile.copyWith( 83 + displayName: fetchedProfile.displayName, 84 + description: fetchedProfile.description, 85 + handle: fetchedProfile.handle, 86 + avatar: fetchedProfile.avatar != null ? Uri.parse(fetchedProfile.avatar!) : null, 87 + ); 88 + } 89 + } 90 + } 91 + } 92 + } 93 + 94 + Future<void> fetchMore() async { 95 + if (state.value == null || state.value!.cursor == null || state.value!.isFetchingMore) return; 96 + 97 + state = AsyncValue.data(state.value!.copyWith(isFetchingMore: true)); 98 + 99 + try { 100 + List<ProfileView> newProfiles; 101 + String? newCursor; 102 + 103 + if (type == UserListType.followers) { 104 + final response = await _graphRepository.getFollowers(did, cursor: state.value!.cursor); 105 + newProfiles = response.followers.toList(); 106 + newCursor = response.cursor; 107 + } else { 108 + final response = await _graphRepository.getFollows(did, cursor: state.value!.cursor); 109 + newProfiles = response.follows.toList(); 110 + newCursor = response.cursor; 111 + } 112 + 113 + await _fetchAndMergeProfilesFromBsky(newProfiles); 114 + 115 + state = AsyncValue.data( 116 + state.value!.copyWith( 117 + profiles: [...state.value!.profiles, ...newProfiles], 118 + cursor: newCursor, 119 + isFetchingMore: false, 120 + updateCursor: true, 121 + ), 122 + ); 123 + } catch (e) { 124 + // Revert on error 125 + state = AsyncValue.data(state.value!.copyWith(isFetchingMore: false)); 126 + } 127 + } 128 + 129 + Future<void> toggleFollow(String did) async { 130 + final currentState = state.valueOrNull; 131 + if (currentState == null) return; 132 + 133 + final userIndex = currentState.profiles.indexWhere((user) => user.did == did); 134 + if (userIndex == -1) return; 135 + 136 + final user = currentState.profiles[userIndex]; 137 + final isCurrentlyFollowing = user.viewer?.following != null; 138 + final currentFollowUri = user.viewer?.following; 139 + 140 + // Optimistic UI update 141 + final updatedUser = user.copyWith( 142 + viewer: user.viewer?.copyWith(following: isCurrentlyFollowing ? null : AtUri.parse('at://temp/uri')), 143 + ); 144 + final newList = List<ProfileView>.from(currentState.profiles); 145 + newList[userIndex] = updatedUser; 146 + state = AsyncValue.data(currentState.copyWith(profiles: newList)); 147 + 148 + try { 149 + final newUriString = await _graphRepository.toggleFollow(did, currentFollowUri); 150 + final newUri = newUriString != null ? AtUri.parse(newUriString) : null; 151 + 152 + // Final state update with correct URI 153 + final finalUser = user.copyWith( 154 + viewer: user.viewer?.copyWith(following: newUri), 155 + ); 156 + final finalList = List<ProfileView>.from(state.value!.profiles); 157 + finalList[userIndex] = finalUser; 158 + state = AsyncValue.data(currentState.copyWith(profiles: finalList)); 159 + } catch (e) { 160 + // Revert on error 161 + state = AsyncValue.data(currentState); 162 + // Optionally, show an error message to the user 163 + } 164 + } 165 + }
+78
lib/src/features/profile/ui/pages/user_list_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:sparksocial/src/features/profile/providers/user_list_provider.dart'; 5 + import 'package:sparksocial/src/features/profile/ui/widgets/user_list_view.dart'; 6 + 7 + enum UserListType { followers, following } 8 + 9 + @RoutePage() 10 + class UserListPage extends ConsumerStatefulWidget { 11 + final String did; 12 + final UserListType type; 13 + 14 + const UserListPage({required this.did, required this.type, super.key}); 15 + 16 + @override 17 + ConsumerState<UserListPage> createState() => _UserListPageState(); 18 + } 19 + 20 + class _UserListPageState extends ConsumerState<UserListPage> { 21 + final _scrollController = ScrollController(); 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _scrollController.addListener(_onScroll); 27 + } 28 + 29 + @override 30 + void dispose() { 31 + _scrollController.removeListener(_onScroll); 32 + _scrollController.dispose(); 33 + super.dispose(); 34 + } 35 + 36 + void _onScroll() { 37 + if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) { 38 + ref.read(userListProvider(did: widget.did, type: widget.type).notifier).fetchMore(); 39 + } 40 + } 41 + 42 + @override 43 + Widget build(BuildContext context) { 44 + final userListAsync = ref.watch(userListProvider(did: widget.did, type: widget.type)); 45 + final title = widget.type == UserListType.followers ? 'Followers' : 'Following'; 46 + 47 + return Scaffold( 48 + appBar: AppBar( 49 + title: Text(title), 50 + ), 51 + body: RefreshIndicator( 52 + onRefresh: () async { 53 + ref.invalidate(userListProvider(did: widget.did, type: widget.type)); 54 + await ref.read(userListProvider(did: widget.did, type: widget.type).future); 55 + }, 56 + child: userListAsync.when( 57 + data: (userList) => UserListView( 58 + users: userList.profiles, 59 + scrollController: _scrollController, 60 + isFetchingMore: userList.isFetchingMore, 61 + ), 62 + loading: () => const Center(child: CircularProgressIndicator()), 63 + error: (error, stack) => ListView( 64 + physics: const AlwaysScrollableScrollPhysics(), 65 + children: [ 66 + Center( 67 + child: Padding( 68 + padding: const EdgeInsets.all(16), 69 + child: Text('An error occurred: $error'), 70 + ), 71 + ), 72 + ], 73 + ), 74 + ), 75 + ), 76 + ); 77 + } 78 + }
+14 -7
lib/src/features/profile/ui/widgets/profile_header.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 2 1 import 'package:auto_route/auto_route.dart'; 3 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 3 import 'package:flutter/material.dart'; ··· 13 12 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 14 13 import 'package:sparksocial/src/core/utils/text_formatter.dart'; 15 14 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 16 - // Local imports for other profile widgets that will be migrated 15 + import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 17 16 import 'package:sparksocial/src/features/profile/ui/widgets/profile_description.dart'; 18 - import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; // Placeholder will be created 19 - import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; // Placeholder will be created 17 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; 18 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; 20 19 21 20 class ProfileHeader extends StatefulWidget { 22 21 const ProfileHeader({ ··· 122 121 } 123 122 124 123 final Widget avatarWidget; 125 - if (widget.profile.avatar case final AtUri av when av.toString().isNotEmpty) { 124 + if (widget.profile.avatar case final Uri av when av.toString().isNotEmpty) { 126 125 avatarWidget = ClipOval( 127 126 child: UserAvatar(imageUrl: av.toString(), username: displayNameForAvatar, size: 90), 128 127 ); ··· 214 213 mainAxisAlignment: MainAxisAlignment.spaceEvenly, 215 214 children: [ 216 215 ProfileStatItem(count: postsCount, label: 'Posts'), 217 - ProfileStatItem(count: followersCount, label: 'Followers'), 218 - ProfileStatItem(count: followsCount, label: 'Following'), 216 + GestureDetector( 217 + onTap: () => context.router.push(UserListRoute(did: widget.profile.did, type: UserListType.followers)), 218 + behavior: HitTestBehavior.opaque, 219 + child: ProfileStatItem(count: followersCount, label: 'Followers'), 220 + ), 221 + GestureDetector( 222 + onTap: () => context.router.push(UserListRoute(did: widget.profile.did, type: UserListType.following)), 223 + behavior: HitTestBehavior.opaque, 224 + child: ProfileStatItem(count: followsCount, label: 'Following'), 225 + ), 219 226 ], 220 227 ), 221 228 ),
+58
lib/src/features/profile/ui/widgets/user_list_view.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 5 + import 'package:sparksocial/src/core/routing/app_router.dart'; 6 + import 'package:sparksocial/src/features/profile/providers/user_list_provider.dart'; 7 + import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 8 + import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 9 + 10 + class UserListView extends ConsumerWidget { 11 + final List<ProfileView> users; 12 + final ScrollController? scrollController; 13 + final bool isFetchingMore; 14 + 15 + const UserListView({required this.users, this.scrollController, this.isFetchingMore = false, super.key}); 16 + 17 + @override 18 + Widget build(BuildContext context, WidgetRef ref) { 19 + if (users.isEmpty) { 20 + return const Center( 21 + child: Text('No users to display.'), 22 + ); 23 + } 24 + 25 + return ListView.builder( 26 + controller: scrollController, 27 + itemCount: users.length + (isFetchingMore ? 1 : 0), 28 + itemBuilder: (context, index) { 29 + if (isFetchingMore && index == users.length) { 30 + return const Center( 31 + child: Padding( 32 + padding: EdgeInsets.all(8), 33 + child: CircularProgressIndicator(), 34 + ), 35 + ); 36 + } 37 + final user = users[index]; 38 + return Padding( 39 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 40 + child: SuggestedAccountCard( 41 + username: user.displayName ?? user.handle, 42 + handle: '@${user.handle}', 43 + avatarUrl: user.avatar.toString(), 44 + description: user.description, 45 + isFollowing: user.viewer?.following != null, 46 + onTap: () => context.router.push(ProfileRoute(did: user.did)), 47 + onFollowTap: () { 48 + ref.read(userListProvider(did: user.did, type: UserListType.followers).notifier).toggleFollow(user.did); 49 + }, 50 + onUnfollowTap: () { 51 + ref.read(userListProvider(did: user.did, type: UserListType.following).notifier).toggleFollow(user.did); 52 + }, 53 + ), 54 + ); 55 + }, 56 + ); 57 + } 58 + }