diff --git a/lib/main.dart b/lib/main.dart index d4641b33..a6613baf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; import 'package:fvp/fvp.dart' as fvp; - import 'package:sparksocial/src/core/di/service_locator.dart'; import 'package:sparksocial/src/core/theme/data/models/app_theme.dart'; import 'package:sparksocial/src/core/utils/logging/logging.dart'; diff --git a/lib/src/core/network/atproto/data/models/actor_models.dart b/lib/src/core/network/atproto/data/models/actor_models.dart index 3f41edfd..204f7fb0 100644 --- a/lib/src/core/network/atproto/data/models/actor_models.dart +++ b/lib/src/core/network/atproto/data/models/actor_models.dart @@ -1,6 +1,7 @@ import 'package:atproto/atproto.dart'; import 'package:atproto_core/atproto_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sparksocial/src/core/utils/uri_converter.dart'; part 'actor_models.freezed.dart'; part 'actor_models.g.dart'; @@ -48,7 +49,7 @@ class ProfileViewBasic with _$ProfileViewBasic { required String did, required String handle, String? displayName, - @AtUriConverter() AtUri? avatar, + @UriConverter() Uri? avatar, // associated: lists, feedgens, starterpacks, labelers, chat?? not needed for now ActorViewer? viewer, List? stories, @@ -66,7 +67,7 @@ class ProfileView with _$ProfileView { required String handle, String? displayName, String? description, - @AtUriConverter() AtUri? avatar, + @UriConverter() Uri? avatar, // associated: lists, feedgens, starterpacks, labelers, chat?? not needed for now // indexedAt and createdAt ActorViewer? viewer, @@ -109,8 +110,8 @@ class ProfileViewDetailed with _$ProfileViewDetailed { required String handle, String? displayName, String? description, - @AtUriConverter() AtUri? avatar, - @AtUriConverter() AtUri? banner, + @UriConverter() Uri? avatar, + @UriConverter() Uri? banner, int? followersCount, int? followsCount, int? postsCount, diff --git a/lib/src/core/network/atproto/data/repositories/graph_repository.dart b/lib/src/core/network/atproto/data/repositories/graph_repository.dart index 89706f1a..141ed2a9 100644 --- a/lib/src/core/network/atproto/data/repositories/graph_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/graph_repository.dart @@ -6,12 +6,14 @@ abstract class GraphRepository { /// Get followers for a DID /// /// [did] The DID to get followers for - Future getFollowers(String did); + /// [cursor] Optional cursor for pagination + Future getFollowers(String did, {String? cursor}); /// Get follows for a DID /// /// [did] The DID to get follows for - Future getFollows(String did); + /// [cursor] Optional cursor for pagination + Future getFollows(String did, {String? cursor}); /// Follow a user /// diff --git a/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart index d2643121..debda5ad 100644 --- a/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart @@ -20,8 +20,8 @@ class GraphRepositoryImpl implements GraphRepository { final SparkLogger _logger = GetIt.instance().getLogger('GraphRepository'); @override - Future getFollowers(String did) async { - _logger.d('Getting followers for DID: $did'); + Future getFollowers(String did, {String? cursor}) async { + _logger.d('Getting followers for DID: $did with cursor: $cursor'); return _client.executeWithRetry(() async { if (!_client.authRepository.isAuthenticated) { _logger.w('Not authenticated'); @@ -33,22 +33,30 @@ class GraphRepositoryImpl implements GraphRepository { _logger.e('AtProto not initialized'); throw Exception('AtProto not initialized'); } - - final result = await atproto.get( - NSID.parse('so.sprk.graph.getFollowers'), - parameters: {'actor': did}, - headers: {'atproto-proxy': _client.sprkDid}, - to: (jsonMap) => jsonMap, - adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List)) as Map, - ); - _logger.d('Followers retrieved successfully'); - return FollowersResponse.fromJson(result.data as Map); + try { + final params = {'actor': did}; + if (cursor != null) { + params['cursor'] = cursor; + } + final result = await atproto.get( + NSID.parse('so.sprk.graph.getFollowers'), + parameters: params, + headers: {'atproto-proxy': _client.sprkDid}, + to: (jsonMap) => jsonMap, + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List)) as Map, + ); + _logger.d('Followers retrieved successfully'); + return FollowersResponse.fromJson(result.data as Map); + } on FormatException catch (fe) { + _logger.e('Error retrieving followers for DID: $did', error: fe); + throw Exception('Failed to retrieve followers for DID: $did'); + } }); } @override - Future getFollows(String did) async { - _logger.d('Getting follows for DID: $did'); + Future getFollows(String did, {String? cursor}) async { + _logger.d('Getting follows for DID: $did with cursor: $cursor'); return _client.executeWithRetry(() async { if (!_client.authRepository.isAuthenticated) { _logger.w('Not authenticated'); @@ -60,16 +68,24 @@ class GraphRepositoryImpl implements GraphRepository { _logger.e('AtProto not initialized'); throw Exception('AtProto not initialized'); } - - final result = await atproto.get( - NSID.parse('so.sprk.graph.getFollows'), - parameters: {'actor': did}, - headers: {'atproto-proxy': _client.sprkDid}, - to: (jsonMap) => jsonMap, - adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List)) as Map, - ); - _logger.d('Follows retrieved successfully'); - return FollowsResponse.fromJson(result.data as Map); + try { + final params = {'actor': did}; + if (cursor != null) { + params['cursor'] = cursor; + } + final result = await atproto.get( + NSID.parse('so.sprk.graph.getFollows'), + parameters: params, + headers: {'atproto-proxy': _client.sprkDid}, + to: (jsonMap) => jsonMap, + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List)) as Map, + ); + _logger.d('Follows retrieved successfully'); + return FollowsResponse.fromJson(result.data as Map); + } on FormatException catch (fe) { + _logger.e('Error retrieving follows for DID: $did', error: fe); + throw Exception('Failed to retrieve follows for DID: $did'); + } }); } diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index 50ba2fc8..1c70cc20 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sparksocial/src/core/network/atproto/atproto.dart'; import 'package:sparksocial/src/core/routing/pages.dart'; +import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; import 'package:video_player/video_player.dart'; part 'app_router.gr.dart'; @@ -76,6 +77,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: ProfilePhotosRoute.page, path: 'photos'), ], ), + AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), AutoRoute(page: VideoPlaybackRoute.page, path: '/video-playback'), AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), AutoRoute(page: StoryReviewRoute.page, path: '/story-review'), diff --git a/lib/src/core/utils/uri_converter.dart b/lib/src/core/utils/uri_converter.dart new file mode 100644 index 00000000..9ba070fe --- /dev/null +++ b/lib/src/core/utils/uri_converter.dart @@ -0,0 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +class UriConverter implements JsonConverter { + const UriConverter(); + + @override + Uri fromJson(String json) => Uri.parse(json); + + @override + String toJson(Uri object) => object.toString(); +} diff --git a/lib/src/features/profile/providers/user_list_provider.dart b/lib/src/features/profile/providers/user_list_provider.dart new file mode 100644 index 00000000..1b91ad47 --- /dev/null +++ b/lib/src/features/profile/providers/user_list_provider.dart @@ -0,0 +1,165 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/bluesky.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; +import 'package:sparksocial/src/core/di/service_locator.dart'; +import 'package:sparksocial/src/core/network/atproto/atproto.dart'; +import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; + +part 'user_list_provider.g.dart'; + +class PaginatedUserList { + final List profiles; + final String? cursor; + final bool isFetchingMore; + + PaginatedUserList({required this.profiles, this.cursor, this.isFetchingMore = false}); + + PaginatedUserList copyWith({ + List? profiles, + String? cursor, + bool? isFetchingMore, + bool updateCursor = false, + }) { + // remove profiles with unknown.invalid handle + profiles?.removeWhere((profile) => profile.handle == 'unknown.invalid' || profile.handle.isEmpty); + return PaginatedUserList( + profiles: profiles ?? this.profiles, + cursor: updateCursor ? cursor : this.cursor, + isFetchingMore: isFetchingMore ?? this.isFetchingMore, + ); + } +} + +@Riverpod(keepAlive: true) +class UserList extends _$UserList { + final GraphRepository _graphRepository = sl(); + final AuthRepository _authRepository = sl(); + + @override + Future build({required String did, required UserListType type}) async { + List profiles; + String? cursor; + + if (type == UserListType.followers) { + final response = await _graphRepository.getFollowers(did); + profiles = response.followers.toList(); + cursor = response.cursor; + } else { + final response = await _graphRepository.getFollows(did); + profiles = response.follows.toList(); + cursor = response.cursor; + } + + await _fetchAndMergeProfilesFromBsky(profiles); + + // remove profiles with unknown.invalid handle + profiles.removeWhere((profile) => profile.handle == 'unknown.invalid' || profile.handle.isEmpty); + + return PaginatedUserList(profiles: profiles, cursor: cursor); + } + + Future _fetchAndMergeProfilesFromBsky(List profiles) async { + final didsToFetch = profiles.where((profile) => profile.displayName == null).map((profile) => profile.did).toList(); + + if (didsToFetch.isNotEmpty) { + final session = _authRepository.session; + if (session != null) { + final bsky = Bluesky.fromSession(session); + final fetchedProfiles = []; + + for (var i = 0; i < didsToFetch.length; i += 25) { + final batch = didsToFetch.sublist(i, i + 25 > didsToFetch.length ? didsToFetch.length : i + 25); + final profilesResponse = await bsky.actor.getProfiles(actors: batch); + fetchedProfiles.addAll(profilesResponse.data.profiles); + } + final profilesMap = {for (final p in fetchedProfiles) p.did: p}; + + for (var i = 0; i < profiles.length; i++) { + final profile = profiles[i]; + if (profilesMap.containsKey(profile.did)) { + final fetchedProfile = profilesMap[profile.did]!; + profiles[i] = profile.copyWith( + displayName: fetchedProfile.displayName, + description: fetchedProfile.description, + handle: fetchedProfile.handle, + avatar: fetchedProfile.avatar != null ? Uri.parse(fetchedProfile.avatar!) : null, + ); + } + } + } + } + } + + Future fetchMore() async { + if (state.value == null || state.value!.cursor == null || state.value!.isFetchingMore) return; + + state = AsyncValue.data(state.value!.copyWith(isFetchingMore: true)); + + try { + List newProfiles; + String? newCursor; + + if (type == UserListType.followers) { + final response = await _graphRepository.getFollowers(did, cursor: state.value!.cursor); + newProfiles = response.followers.toList(); + newCursor = response.cursor; + } else { + final response = await _graphRepository.getFollows(did, cursor: state.value!.cursor); + newProfiles = response.follows.toList(); + newCursor = response.cursor; + } + + await _fetchAndMergeProfilesFromBsky(newProfiles); + + state = AsyncValue.data( + state.value!.copyWith( + profiles: [...state.value!.profiles, ...newProfiles], + cursor: newCursor, + isFetchingMore: false, + updateCursor: true, + ), + ); + } catch (e) { + // Revert on error + state = AsyncValue.data(state.value!.copyWith(isFetchingMore: false)); + } + } + + Future toggleFollow(String did) async { + final currentState = state.valueOrNull; + if (currentState == null) return; + + final userIndex = currentState.profiles.indexWhere((user) => user.did == did); + if (userIndex == -1) return; + + final user = currentState.profiles[userIndex]; + final isCurrentlyFollowing = user.viewer?.following != null; + final currentFollowUri = user.viewer?.following; + + // Optimistic UI update + final updatedUser = user.copyWith( + viewer: user.viewer?.copyWith(following: isCurrentlyFollowing ? null : AtUri.parse('at://temp/uri')), + ); + final newList = List.from(currentState.profiles); + newList[userIndex] = updatedUser; + state = AsyncValue.data(currentState.copyWith(profiles: newList)); + + try { + final newUriString = await _graphRepository.toggleFollow(did, currentFollowUri); + final newUri = newUriString != null ? AtUri.parse(newUriString) : null; + + // Final state update with correct URI + final finalUser = user.copyWith( + viewer: user.viewer?.copyWith(following: newUri), + ); + final finalList = List.from(state.value!.profiles); + finalList[userIndex] = finalUser; + state = AsyncValue.data(currentState.copyWith(profiles: finalList)); + } catch (e) { + // Revert on error + state = AsyncValue.data(currentState); + // Optionally, show an error message to the user + } + } +} diff --git a/lib/src/features/profile/ui/pages/user_list_page.dart b/lib/src/features/profile/ui/pages/user_list_page.dart new file mode 100644 index 00000000..b662875d --- /dev/null +++ b/lib/src/features/profile/ui/pages/user_list_page.dart @@ -0,0 +1,78 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sparksocial/src/features/profile/providers/user_list_provider.dart'; +import 'package:sparksocial/src/features/profile/ui/widgets/user_list_view.dart'; + +enum UserListType { followers, following } + +@RoutePage() +class UserListPage extends ConsumerStatefulWidget { + final String did; + final UserListType type; + + const UserListPage({required this.did, required this.type, super.key}); + + @override + ConsumerState createState() => _UserListPageState(); +} + +class _UserListPageState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) { + ref.read(userListProvider(did: widget.did, type: widget.type).notifier).fetchMore(); + } + } + + @override + Widget build(BuildContext context) { + final userListAsync = ref.watch(userListProvider(did: widget.did, type: widget.type)); + final title = widget.type == UserListType.followers ? 'Followers' : 'Following'; + + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(userListProvider(did: widget.did, type: widget.type)); + await ref.read(userListProvider(did: widget.did, type: widget.type).future); + }, + child: userListAsync.when( + data: (userList) => UserListView( + users: userList.profiles, + scrollController: _scrollController, + isFetchingMore: userList.isFetchingMore, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('An error occurred: $error'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/profile/ui/widgets/profile_header.dart b/lib/src/features/profile/ui/widgets/profile_header.dart index 6fc98779..722dc7c3 100644 --- a/lib/src/features/profile/ui/widgets/profile_header.dart +++ b/lib/src/features/profile/ui/widgets/profile_header.dart @@ -1,4 +1,3 @@ -import 'package:atproto_core/atproto_core.dart'; import 'package:auto_route/auto_route.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; @@ -13,10 +12,10 @@ import 'package:sparksocial/src/core/utils/logging/log_service.dart'; import 'package:sparksocial/src/core/utils/logging/logger.dart'; import 'package:sparksocial/src/core/utils/text_formatter.dart'; import 'package:sparksocial/src/core/widgets/user_avatar.dart'; -// Local imports for other profile widgets that will be migrated +import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; import 'package:sparksocial/src/features/profile/ui/widgets/profile_description.dart'; -import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; // Placeholder will be created -import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; // Placeholder will be created +import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; +import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; class ProfileHeader extends StatefulWidget { const ProfileHeader({ @@ -122,7 +121,7 @@ class _ProfileHeaderState extends State { } final Widget avatarWidget; - if (widget.profile.avatar case final AtUri av when av.toString().isNotEmpty) { + if (widget.profile.avatar case final Uri av when av.toString().isNotEmpty) { avatarWidget = ClipOval( child: UserAvatar(imageUrl: av.toString(), username: displayNameForAvatar, size: 90), ); @@ -214,8 +213,16 @@ class _ProfileHeaderState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ProfileStatItem(count: postsCount, label: 'Posts'), - ProfileStatItem(count: followersCount, label: 'Followers'), - ProfileStatItem(count: followsCount, label: 'Following'), + GestureDetector( + onTap: () => context.router.push(UserListRoute(did: widget.profile.did, type: UserListType.followers)), + behavior: HitTestBehavior.opaque, + child: ProfileStatItem(count: followersCount, label: 'Followers'), + ), + GestureDetector( + onTap: () => context.router.push(UserListRoute(did: widget.profile.did, type: UserListType.following)), + behavior: HitTestBehavior.opaque, + child: ProfileStatItem(count: followsCount, label: 'Following'), + ), ], ), ), diff --git a/lib/src/features/profile/ui/widgets/user_list_view.dart b/lib/src/features/profile/ui/widgets/user_list_view.dart new file mode 100644 index 00000000..e2c554d3 --- /dev/null +++ b/lib/src/features/profile/ui/widgets/user_list_view.dart @@ -0,0 +1,58 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:sparksocial/src/core/routing/app_router.dart'; +import 'package:sparksocial/src/features/profile/providers/user_list_provider.dart'; +import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; +import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; + +class UserListView extends ConsumerWidget { + final List users; + final ScrollController? scrollController; + final bool isFetchingMore; + + const UserListView({required this.users, this.scrollController, this.isFetchingMore = false, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (users.isEmpty) { + return const Center( + child: Text('No users to display.'), + ); + } + + return ListView.builder( + controller: scrollController, + itemCount: users.length + (isFetchingMore ? 1 : 0), + itemBuilder: (context, index) { + if (isFetchingMore && index == users.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + ); + } + final user = users[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: SuggestedAccountCard( + username: user.displayName ?? user.handle, + handle: '@${user.handle}', + avatarUrl: user.avatar.toString(), + description: user.description, + isFollowing: user.viewer?.following != null, + onTap: () => context.router.push(ProfileRoute(did: user.did)), + onFollowTap: () { + ref.read(userListProvider(did: user.did, type: UserListType.followers).notifier).toggleFollow(user.did); + }, + onUnfollowTap: () { + ref.read(userListProvider(did: user.did, type: UserListType.following).notifier).toggleFollow(user.did); + }, + ), + ); + }, + ); + } +}