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: profile connections (followers/following/mutuals) screen (#25)

* feat: profile connections (follower & following list screens)

* refactor: move connections route behind :actor

* feat: progressive search

* feat: mutuals tab

* feat: connections load more with error handling and retry

authored by

Owais and committed by
GitHub
bc28ad5f c32639f5

+1847 -32
+2
lib/core/network/app_bsky_routing_policy.dart
··· 63 63 'app.bsky.graph.muteActor': AppBskyProxyMode.bypassProxy, 64 64 'app.bsky.graph.unmuteActor': AppBskyProxyMode.bypassProxy, 65 65 'app.bsky.graph.getSuggestedFollowsByActor': AppBskyProxyMode.bypassProxy, 66 + 'app.bsky.graph.getFollows': AppBskyProxyMode.bypassProxy, 67 + 'app.bsky.graph.getFollowers': AppBskyProxyMode.bypassProxy, 66 68 'app.bsky.graph.getLists': AppBskyProxyMode.bypassProxy, 67 69 'app.bsky.graph.getList': AppBskyProxyMode.bypassProxy, 68 70 'app.bsky.graph.getListsWithMembership': AppBskyProxyMode.bypassProxy,
+21 -16
lib/core/network/constellation_client.dart
··· 8 8 class ConstellationLinkRecord { 9 9 const ConstellationLinkRecord({required this.did, required this.collection, required this.rkey}); 10 10 11 - factory ConstellationLinkRecord.fromJson(Map<String, dynamic> json) { 12 - return ConstellationLinkRecord( 13 - did: json['did'] as String, 14 - collection: json['collection'] as String, 15 - rkey: json['rkey'] as String, 16 - ); 17 - } 11 + factory ConstellationLinkRecord.fromJson(Map<String, dynamic> json) => ConstellationLinkRecord( 12 + did: json['did'] as String, 13 + collection: json['collection'] as String, 14 + rkey: json['rkey'] as String, 15 + ); 18 16 19 17 final String did; 20 18 final String collection; ··· 24 22 class ManyToManyItem { 25 23 const ManyToManyItem({required this.linkRecord, required this.otherSubject}); 26 24 27 - factory ManyToManyItem.fromJson(Map<String, dynamic> json) { 28 - return ManyToManyItem( 29 - linkRecord: ConstellationLinkRecord.fromJson(json['linkRecord'] as Map<String, dynamic>), 30 - otherSubject: json['otherSubject'] as String, 31 - ); 32 - } 25 + factory ManyToManyItem.fromJson(Map<String, dynamic> json) => ManyToManyItem( 26 + linkRecord: ConstellationLinkRecord.fromJson(json['linkRecord'] as Map<String, dynamic>), 27 + otherSubject: json['otherSubject'] as String, 28 + ); 33 29 34 30 final ConstellationLinkRecord linkRecord; 35 31 final String otherSubject; ··· 63 59 return trimmed.replaceFirst(RegExp(r'/+$'), ''); 64 60 } 65 61 66 - Uri _xrpcUri(String endpoint, Map<String, String?> params) { 67 - final filtered = <String, String>{}; 62 + Uri _xrpcUri(String endpoint, Map<String, dynamic> params) { 63 + final filtered = <String, dynamic>{}; 68 64 for (final entry in params.entries) { 69 - if (entry.value != null) filtered[entry.key] = entry.value!; 65 + final value = entry.value; 66 + if (value == null) { 67 + continue; 68 + } 69 + if (value is Iterable && value.isEmpty) { 70 + continue; 71 + } 72 + filtered[entry.key] = value; 70 73 } 71 74 final base = Uri.parse('$_baseUrl/xrpc/$endpoint'); 72 75 return filtered.isEmpty ? base : base.replace(queryParameters: filtered); ··· 128 131 String source, { 129 132 int? limit, 130 133 String? cursor, 134 + List<String> dids = const [], 131 135 }) async { 132 136 final uri = _xrpcUri('blue.microcosm.links.getBacklinks', { 133 137 'subject': subject, 134 138 'source': source, 135 139 if (limit != null) 'limit': limit.toString(), 136 140 'cursor': cursor, 141 + 'did': dids, 137 142 }); 138 143 final data = await _get(uri); 139 144 final records = _listFieldAny(data, [
+40 -1
lib/core/router/app_router.dart
··· 20 20 import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 21 21 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 22 22 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 23 - import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 24 23 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 24 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 25 25 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 26 26 import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; 27 27 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; ··· 50 50 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 51 51 import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 52 52 import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 53 + import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; 53 54 import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 54 55 import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 55 56 import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 57 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 56 58 import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 59 + import 'package:lazurite/features/profile/presentation/profile_connections_screen.dart'; 57 60 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 58 61 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 59 62 import 'package:lazurite/features/search/bloc/search_bloc.dart'; ··· 528 531 GoRoute( 529 532 path: '/profile/me', 530 533 pageBuilder: (context, state) => _page(context, state, const ProfileScreen(actor: 'me')), 534 + routes: [ 535 + GoRoute( 536 + path: 'connections', 537 + pageBuilder: (context, state) => 538 + _page(context, state, _buildProfileConnectionsRoute(context, state, context.read<String>())), 539 + ), 540 + ], 531 541 ), 532 542 GoRoute( 533 543 path: '/profile/:actor', ··· 544 554 ProfileScreen(actor: Uri.decodeComponent(state.pathParameters['actor'] ?? ''), showBackButton: true), 545 555 ), 546 556 routes: [ 557 + GoRoute( 558 + path: 'connections', 559 + pageBuilder: (context, state) => _page( 560 + context, 561 + state, 562 + _buildProfileConnectionsRoute( 563 + context, 564 + state, 565 + Uri.decodeComponent(state.pathParameters['actor'] ?? ''), 566 + ), 567 + ), 568 + ), 547 569 GoRoute( 548 570 path: 'search-posts', 549 571 pageBuilder: (context, state) { ··· 597 619 notificationRepository: context.read<NotificationRepository>(), 598 620 ), 599 621 child: child, 622 + ); 623 + } 624 + 625 + Widget _buildProfileConnectionsRoute(BuildContext context, GoRouterState state, String actor) { 626 + final normalizedActor = actor.trim(); 627 + final initialTab = ProfileConnectionsTabX.fromRouteValue(state.uri.queryParameters['tab']); 628 + final constellationUrl = context.read<SettingsCubit>().state.constellationUrl; 629 + final handle = normalizedActor.startsWith('did:') || normalizedActor == context.read<String>() 630 + ? null 631 + : normalizedActor; 632 + return BlocProvider( 633 + create: (_) => ProfileConnectionsCubit( 634 + repository: context.read<ProfileRepository>(), 635 + actor: normalizedActor, 636 + constellationClient: ConstellationClient(baseUrl: constellationUrl), 637 + ), 638 + child: ProfileConnectionsScreen(actor: normalizedActor, handle: handle, initialTab: initialTab), 600 639 ); 601 640 } 602 641
+578
lib/features/profile/cubit/profile_connections_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:equatable/equatable.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:fuzzywuzzy/fuzzywuzzy.dart' as fuzzywuzzy; 7 + import 'package:lazurite/core/network/constellation_client.dart'; 8 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 9 + 10 + enum ProfileConnectionsTab { following, followers, mutuals } 11 + 12 + extension ProfileConnectionsTabX on ProfileConnectionsTab { 13 + String get routeValue => switch (this) { 14 + ProfileConnectionsTab.following => 'following', 15 + ProfileConnectionsTab.followers => 'followers', 16 + ProfileConnectionsTab.mutuals => 'mutuals', 17 + }; 18 + 19 + String get title => switch (this) { 20 + ProfileConnectionsTab.following => 'Following', 21 + ProfileConnectionsTab.followers => 'Followers', 22 + ProfileConnectionsTab.mutuals => 'Mutuals', 23 + }; 24 + 25 + static ProfileConnectionsTab fromRouteValue(String? value) { 26 + return switch (value) { 27 + 'followers' => ProfileConnectionsTab.followers, 28 + 'mutuals' => ProfileConnectionsTab.mutuals, 29 + _ => ProfileConnectionsTab.following, 30 + }; 31 + } 32 + } 33 + 34 + enum ProfileConnectionsStatus { initial, loading, loaded, error } 35 + 36 + enum ProfileConnectionsSearchStatus { idle, searching, complete, error } 37 + 38 + const _profileConnectionsNoValue = Object(); 39 + 40 + class ProfileConnectionsCubit extends Cubit<ProfileConnectionsState> { 41 + ProfileConnectionsCubit({ 42 + required ProfileRepository repository, 43 + required String actor, 44 + ConstellationClient? constellationClient, 45 + }) : _repository = repository, 46 + _constellationClient = constellationClient, 47 + _actor = actor, 48 + super(const ProfileConnectionsState()); 49 + 50 + final ProfileRepository _repository; 51 + final ConstellationClient? _constellationClient; 52 + final String _actor; 53 + final Map<ProfileConnectionsTab, int> _searchGenerations = { 54 + ProfileConnectionsTab.following: 0, 55 + ProfileConnectionsTab.followers: 0, 56 + ProfileConnectionsTab.mutuals: 0, 57 + }; 58 + Timer? _searchDebounce; 59 + static const _pageLimit = 100; 60 + static const _searchDebounceDuration = Duration(milliseconds: 300); 61 + static const _searchCutoff = 60; 62 + static const _maxSearchResults = 100; 63 + static const _mutualCandidateBatchSize = 50; 64 + static const _followSource = 'app.bsky.graph.follow:subject'; 65 + 66 + @override 67 + Future<void> close() { 68 + _searchDebounce?.cancel(); 69 + for (final tab in ProfileConnectionsTab.values) { 70 + _cancelSearch(tab); 71 + } 72 + return super.close(); 73 + } 74 + 75 + Future<void> loadTab(ProfileConnectionsTab tab, {bool force = false}) async { 76 + final data = state.dataFor(tab); 77 + if (!force && (data.status == ProfileConnectionsStatus.loading || data.status == ProfileConnectionsStatus.loaded)) { 78 + return; 79 + } 80 + 81 + emit( 82 + state.copyWithTab( 83 + tab, 84 + data.copyWith(status: ProfileConnectionsStatus.loading, errorMessage: null, loadMoreErrorMessage: null), 85 + ), 86 + ); 87 + 88 + try { 89 + final page = await _fetch(tab); 90 + emit( 91 + state.copyWithTab( 92 + tab, 93 + data.copyWith( 94 + status: ProfileConnectionsStatus.loaded, 95 + profiles: page.profiles, 96 + cursor: page.cursor, 97 + subject: page.subject, 98 + errorMessage: null, 99 + loadMoreErrorMessage: null, 100 + ), 101 + ), 102 + ); 103 + } catch (error) { 104 + emit( 105 + state.copyWithTab( 106 + tab, 107 + data.copyWith(status: ProfileConnectionsStatus.error, errorMessage: 'Failed to load ${tab.title}: $error'), 108 + ), 109 + ); 110 + } 111 + } 112 + 113 + Future<void> refreshTab(ProfileConnectionsTab tab) async { 114 + emit(state.copyWithTab(tab, state.dataFor(tab).copyWith(cursor: null, profiles: const []))); 115 + await loadTab(tab, force: true); 116 + ensureSearchForTab(tab, force: true); 117 + } 118 + 119 + Future<void> loadMore(ProfileConnectionsTab tab) async { 120 + final data = state.dataFor(tab); 121 + if (data.cursor == null || data.isLoadingMore || data.status != ProfileConnectionsStatus.loaded) { 122 + return; 123 + } 124 + 125 + emit(state.copyWithTab(tab, data.copyWith(isLoadingMore: true, loadMoreErrorMessage: null))); 126 + 127 + try { 128 + final page = await _fetch(tab, cursor: data.cursor); 129 + emit( 130 + state.copyWithTab( 131 + tab, 132 + data.copyWith( 133 + profiles: [...data.profiles, ...page.profiles], 134 + cursor: page.cursor, 135 + subject: page.subject, 136 + isLoadingMore: false, 137 + errorMessage: null, 138 + loadMoreErrorMessage: null, 139 + ), 140 + ), 141 + ); 142 + } catch (error) { 143 + emit( 144 + state.copyWithTab( 145 + tab, 146 + data.copyWith(isLoadingMore: false, loadMoreErrorMessage: 'Failed to load more: $error'), 147 + ), 148 + ); 149 + } 150 + } 151 + 152 + void setSearchQuery(String query, ProfileConnectionsTab activeTab) { 153 + final normalizedQuery = query.trim(); 154 + _searchDebounce?.cancel(); 155 + 156 + if (normalizedQuery != state.searchQuery) { 157 + for (final tab in ProfileConnectionsTab.values) { 158 + _cancelSearch(tab); 159 + } 160 + emit( 161 + state.copyWith( 162 + searchQuery: normalizedQuery, 163 + following: state.following.clearSearch(), 164 + followers: state.followers.clearSearch(), 165 + mutuals: state.mutuals.clearSearch(), 166 + ), 167 + ); 168 + } 169 + 170 + if (normalizedQuery.isEmpty) { 171 + return; 172 + } 173 + 174 + _searchDebounce = Timer(_searchDebounceDuration, () { 175 + ensureSearchForTab(activeTab); 176 + }); 177 + } 178 + 179 + void ensureSearchForTab(ProfileConnectionsTab tab, {bool force = false}) { 180 + final query = state.searchQuery; 181 + if (query.isEmpty) { 182 + return; 183 + } 184 + 185 + final data = state.dataFor(tab); 186 + if (!force && 187 + data.searchQuery == query && 188 + (data.searchStatus == ProfileConnectionsSearchStatus.searching || 189 + data.searchStatus == ProfileConnectionsSearchStatus.complete)) { 190 + return; 191 + } 192 + 193 + unawaited(_searchFullList(tab, query)); 194 + } 195 + 196 + Future<ProfileConnectionsPage> _fetch(ProfileConnectionsTab tab, {String? cursor}) { 197 + return switch (tab) { 198 + ProfileConnectionsTab.following => _repository.getFollowing(actor: _actor, cursor: cursor, limit: _pageLimit), 199 + ProfileConnectionsTab.followers => _repository.getFollowers(actor: _actor, cursor: cursor, limit: _pageLimit), 200 + ProfileConnectionsTab.mutuals => _getMutuals(cursor: cursor), 201 + }; 202 + } 203 + 204 + Future<ProfileConnectionsPage> _getMutuals({String? cursor}) async { 205 + final constellationClient = _constellationClient; 206 + if (constellationClient == null) { 207 + throw StateError('Constellation client is required to load mutual follows.'); 208 + } 209 + 210 + final mutualProfiles = <ProfileView>[]; 211 + late ProfileView subject; 212 + String? nextCursor = cursor; 213 + 214 + do { 215 + final followsPage = await _repository.getFollowing(actor: _actor, cursor: nextCursor, limit: _pageLimit); 216 + subject = followsPage.subject; 217 + nextCursor = followsPage.cursor; 218 + 219 + final candidatesByDid = {for (final profile in followsPage.profiles) profile.did: profile}; 220 + if (candidatesByDid.isEmpty) { 221 + continue; 222 + } 223 + 224 + final mutualDids = await _getMutualDids( 225 + constellationClient: constellationClient, 226 + subjectDid: followsPage.subject.did, 227 + candidateDids: candidatesByDid.keys.toList(growable: false), 228 + ); 229 + for (final did in mutualDids) { 230 + final profile = candidatesByDid[did]; 231 + if (profile != null) { 232 + mutualProfiles.add(profile); 233 + } 234 + } 235 + } while (mutualProfiles.isEmpty && nextCursor != null); 236 + 237 + return ProfileConnectionsPage(subject: subject, profiles: mutualProfiles, cursor: nextCursor); 238 + } 239 + 240 + Future<Set<String>> _getMutualDids({ 241 + required ConstellationClient constellationClient, 242 + required String subjectDid, 243 + required List<String> candidateDids, 244 + }) async { 245 + final mutualDids = <String>{}; 246 + for (var i = 0; i < candidateDids.length; i += _mutualCandidateBatchSize) { 247 + final batch = candidateDids.sublist(i, (i + _mutualCandidateBatchSize).clamp(0, candidateDids.length)); 248 + String? cursor; 249 + do { 250 + final result = await constellationClient.getBacklinks( 251 + subjectDid, 252 + _followSource, 253 + limit: _pageLimit, 254 + cursor: cursor, 255 + dids: batch, 256 + ); 257 + mutualDids.addAll(result.records.map((record) => record.did)); 258 + cursor = result.cursor; 259 + } while (cursor != null); 260 + } 261 + return mutualDids; 262 + } 263 + 264 + Future<void> _searchFullList(ProfileConnectionsTab tab, String query) async { 265 + final generation = _nextSearchGeneration(tab); 266 + final matchesByDid = <String, _ProfileSearchMatch>{}; 267 + var searchedCount = 0; 268 + String? cursor; 269 + 270 + emit( 271 + state.copyWithTab( 272 + tab, 273 + state 274 + .dataFor(tab) 275 + .copyWith( 276 + searchStatus: ProfileConnectionsSearchStatus.searching, 277 + searchQuery: query, 278 + searchResults: const [], 279 + searchedCount: 0, 280 + searchErrorMessage: null, 281 + ), 282 + ), 283 + ); 284 + 285 + try { 286 + while (true) { 287 + if (!_isCurrentSearch(tab, generation, query)) { 288 + return; 289 + } 290 + 291 + final page = await _fetch(tab, cursor: cursor); 292 + if (!_isCurrentSearch(tab, generation, query)) { 293 + return; 294 + } 295 + 296 + searchedCount += page.profiles.length; 297 + _mergeSearchMatches(query, matchesByDid, page.profiles); 298 + final topMatches = _topSearchResults(matchesByDid); 299 + final currentData = state.dataFor(tab); 300 + emit( 301 + state.copyWithTab( 302 + tab, 303 + currentData.copyWith( 304 + status: currentData.status == ProfileConnectionsStatus.initial 305 + ? ProfileConnectionsStatus.loaded 306 + : currentData.status, 307 + subject: page.subject, 308 + searchStatus: ProfileConnectionsSearchStatus.searching, 309 + searchQuery: query, 310 + searchResults: topMatches, 311 + searchedCount: searchedCount, 312 + searchErrorMessage: null, 313 + ), 314 + ), 315 + ); 316 + 317 + cursor = page.cursor; 318 + if (cursor == null) { 319 + break; 320 + } 321 + await Future<void>.delayed(Duration.zero); 322 + } 323 + 324 + if (!_isCurrentSearch(tab, generation, query)) { 325 + return; 326 + } 327 + 328 + emit( 329 + state.copyWithTab( 330 + tab, 331 + state 332 + .dataFor(tab) 333 + .copyWith( 334 + searchStatus: ProfileConnectionsSearchStatus.complete, 335 + searchQuery: query, 336 + searchedCount: searchedCount, 337 + searchErrorMessage: null, 338 + ), 339 + ), 340 + ); 341 + } catch (error) { 342 + if (!_isCurrentSearch(tab, generation, query)) { 343 + return; 344 + } 345 + emit( 346 + state.copyWithTab( 347 + tab, 348 + state 349 + .dataFor(tab) 350 + .copyWith( 351 + searchStatus: ProfileConnectionsSearchStatus.error, 352 + searchQuery: query, 353 + searchedCount: searchedCount, 354 + searchErrorMessage: 'Search stopped: $error', 355 + ), 356 + ), 357 + ); 358 + } 359 + } 360 + 361 + int _nextSearchGeneration(ProfileConnectionsTab tab) { 362 + final next = (_searchGenerations[tab] ?? 0) + 1; 363 + _searchGenerations[tab] = next; 364 + return next; 365 + } 366 + 367 + void _cancelSearch(ProfileConnectionsTab tab) { 368 + _searchGenerations[tab] = (_searchGenerations[tab] ?? 0) + 1; 369 + } 370 + 371 + bool _isCurrentSearch(ProfileConnectionsTab tab, int generation, String query) { 372 + return !isClosed && _searchGenerations[tab] == generation && state.searchQuery == query; 373 + } 374 + 375 + void _mergeSearchMatches(String query, Map<String, _ProfileSearchMatch> matchesByDid, List<ProfileView> profiles) { 376 + for (final profile in profiles) { 377 + final score = fuzzywuzzy.weightedRatio(query, ProfileConnectionsState.searchTextForProfile(profile)); 378 + if (score < _searchCutoff) { 379 + continue; 380 + } 381 + 382 + final existing = matchesByDid[profile.did]; 383 + if (existing == null || score > existing.score) { 384 + matchesByDid[profile.did] = _ProfileSearchMatch(profile: profile, score: score); 385 + } 386 + } 387 + 388 + final ranked = matchesByDid.values.toList()..sort(_compareSearchMatches); 389 + if (ranked.length <= _maxSearchResults) { 390 + return; 391 + } 392 + 393 + matchesByDid 394 + ..clear() 395 + ..addEntries(ranked.take(_maxSearchResults).map((match) => MapEntry(match.profile.did, match))); 396 + } 397 + 398 + List<ProfileView> _topSearchResults(Map<String, _ProfileSearchMatch> matchesByDid) { 399 + final ranked = matchesByDid.values.toList()..sort(_compareSearchMatches); 400 + return ranked.map((match) => match.profile).toList(growable: false); 401 + } 402 + 403 + int _compareSearchMatches(_ProfileSearchMatch a, _ProfileSearchMatch b) { 404 + final scoreCompare = b.score.compareTo(a.score); 405 + if (scoreCompare != 0) { 406 + return scoreCompare; 407 + } 408 + return a.profile.handle.compareTo(b.profile.handle); 409 + } 410 + } 411 + 412 + class _ProfileSearchMatch { 413 + const _ProfileSearchMatch({required this.profile, required this.score}); 414 + 415 + final ProfileView profile; 416 + final int score; 417 + } 418 + 419 + class ProfileConnectionsState extends Equatable { 420 + const ProfileConnectionsState({ 421 + this.following = const ProfileConnectionsTabData(), 422 + this.followers = const ProfileConnectionsTabData(), 423 + this.mutuals = const ProfileConnectionsTabData(), 424 + this.searchQuery = '', 425 + }); 426 + 427 + final ProfileConnectionsTabData following; 428 + final ProfileConnectionsTabData followers; 429 + final ProfileConnectionsTabData mutuals; 430 + final String searchQuery; 431 + 432 + ProfileConnectionsTabData dataFor(ProfileConnectionsTab tab) => switch (tab) { 433 + ProfileConnectionsTab.following => following, 434 + ProfileConnectionsTab.followers => followers, 435 + ProfileConnectionsTab.mutuals => mutuals, 436 + }; 437 + 438 + List<ProfileView> visibleProfilesFor(ProfileConnectionsTab tab) { 439 + if (searchQuery.isEmpty) { 440 + return dataFor(tab).profiles; 441 + } 442 + 443 + final data = dataFor(tab); 444 + if (data.searchQuery != searchQuery) { 445 + return const []; 446 + } 447 + return data.searchResults; 448 + } 449 + 450 + ProfileConnectionsState copyWith({ 451 + ProfileConnectionsTabData? following, 452 + ProfileConnectionsTabData? followers, 453 + ProfileConnectionsTabData? mutuals, 454 + String? searchQuery, 455 + }) { 456 + return ProfileConnectionsState( 457 + following: following ?? this.following, 458 + followers: followers ?? this.followers, 459 + mutuals: mutuals ?? this.mutuals, 460 + searchQuery: searchQuery ?? this.searchQuery, 461 + ); 462 + } 463 + 464 + ProfileConnectionsState copyWithTab(ProfileConnectionsTab tab, ProfileConnectionsTabData data) { 465 + return switch (tab) { 466 + ProfileConnectionsTab.following => copyWith(following: data), 467 + ProfileConnectionsTab.followers => copyWith(followers: data), 468 + ProfileConnectionsTab.mutuals => copyWith(mutuals: data), 469 + }; 470 + } 471 + 472 + static String searchTextForProfile(ProfileView profile) { 473 + return [ 474 + profile.handle, 475 + profile.displayName, 476 + profile.description, 477 + ].whereType<String>().where((value) => value.trim().isNotEmpty).join(' '); 478 + } 479 + 480 + @override 481 + List<Object?> get props => [following, followers, mutuals, searchQuery]; 482 + } 483 + 484 + class ProfileConnectionsTabData extends Equatable { 485 + const ProfileConnectionsTabData({ 486 + this.status = ProfileConnectionsStatus.initial, 487 + this.profiles = const [], 488 + this.cursor, 489 + this.subject, 490 + this.errorMessage, 491 + this.loadMoreErrorMessage, 492 + this.isLoadingMore = false, 493 + this.searchStatus = ProfileConnectionsSearchStatus.idle, 494 + this.searchQuery = '', 495 + this.searchResults = const [], 496 + this.searchedCount = 0, 497 + this.searchErrorMessage, 498 + }); 499 + 500 + final ProfileConnectionsStatus status; 501 + final List<ProfileView> profiles; 502 + final String? cursor; 503 + final ProfileView? subject; 504 + final String? errorMessage; 505 + final String? loadMoreErrorMessage; 506 + final bool isLoadingMore; 507 + final ProfileConnectionsSearchStatus searchStatus; 508 + final String searchQuery; 509 + final List<ProfileView> searchResults; 510 + final int searchedCount; 511 + final String? searchErrorMessage; 512 + 513 + bool get isLoading => status == ProfileConnectionsStatus.loading; 514 + bool get hasError => status == ProfileConnectionsStatus.error; 515 + bool get hasMore => cursor != null; 516 + bool get isSearching => searchStatus == ProfileConnectionsSearchStatus.searching; 517 + bool get hasActiveSearch => searchQuery.isNotEmpty; 518 + 519 + ProfileConnectionsTabData clearSearch() { 520 + return copyWith( 521 + searchStatus: ProfileConnectionsSearchStatus.idle, 522 + searchQuery: '', 523 + searchResults: const [], 524 + searchedCount: 0, 525 + searchErrorMessage: null, 526 + ); 527 + } 528 + 529 + ProfileConnectionsTabData copyWith({ 530 + ProfileConnectionsStatus? status, 531 + List<ProfileView>? profiles, 532 + Object? cursor = _profileConnectionsNoValue, 533 + Object? subject = _profileConnectionsNoValue, 534 + Object? errorMessage = _profileConnectionsNoValue, 535 + Object? loadMoreErrorMessage = _profileConnectionsNoValue, 536 + bool? isLoadingMore, 537 + ProfileConnectionsSearchStatus? searchStatus, 538 + String? searchQuery, 539 + List<ProfileView>? searchResults, 540 + int? searchedCount, 541 + Object? searchErrorMessage = _profileConnectionsNoValue, 542 + }) { 543 + return ProfileConnectionsTabData( 544 + status: status ?? this.status, 545 + profiles: profiles ?? this.profiles, 546 + cursor: identical(cursor, _profileConnectionsNoValue) ? this.cursor : cursor as String?, 547 + subject: identical(subject, _profileConnectionsNoValue) ? this.subject : subject as ProfileView?, 548 + errorMessage: identical(errorMessage, _profileConnectionsNoValue) ? this.errorMessage : errorMessage as String?, 549 + loadMoreErrorMessage: identical(loadMoreErrorMessage, _profileConnectionsNoValue) 550 + ? this.loadMoreErrorMessage 551 + : loadMoreErrorMessage as String?, 552 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 553 + searchStatus: searchStatus ?? this.searchStatus, 554 + searchQuery: searchQuery ?? this.searchQuery, 555 + searchResults: searchResults ?? this.searchResults, 556 + searchedCount: searchedCount ?? this.searchedCount, 557 + searchErrorMessage: identical(searchErrorMessage, _profileConnectionsNoValue) 558 + ? this.searchErrorMessage 559 + : searchErrorMessage as String?, 560 + ); 561 + } 562 + 563 + @override 564 + List<Object?> get props => [ 565 + status, 566 + profiles, 567 + cursor, 568 + subject, 569 + errorMessage, 570 + loadMoreErrorMessage, 571 + isLoadingMore, 572 + searchStatus, 573 + searchQuery, 574 + searchResults, 575 + searchedCount, 576 + searchErrorMessage, 577 + ]; 578 + }
+50 -2
lib/features/profile/data/profile_repository.dart
··· 54 54 static ActorRepositoryServiceResolver? _createActorRepositoryServiceResolver() { 55 55 try { 56 56 return ActorRepositoryServiceResolver(); 57 - } catch (_) { 57 + } catch (error, stackTrace) { 58 + log.d('ProfileRepository: actor repository service resolver unavailable', error: error, stackTrace: stackTrace); 58 59 return null; 59 60 } 60 61 } ··· 138 139 final moderationService = _moderationService; 139 140 if (moderationService == null) return suggestions; 140 141 return suggestions.where((p) => !moderationService.shouldFilterProfileInList(p)).toList(); 142 + } 143 + 144 + Future<ProfileConnectionsPage> getFollowing({required String actor, String? cursor, int limit = 50}) async { 145 + final headers = _appViewContext.appBskyHeadersForEndpoint( 146 + 'app.bsky.graph.getFollows', 147 + await _moderationService?.headersForRequest(), 148 + ); 149 + final response = await _authRecovery.run( 150 + (client) => client.graph.getFollows(actor: actor, cursor: cursor, limit: limit, $headers: headers), 151 + ); 152 + final profiles = _filterProfileList(response.data.follows as List<ProfileView>); 153 + return ProfileConnectionsPage( 154 + subject: response.data.subject as ProfileView, 155 + profiles: profiles, 156 + cursor: response.data.cursor as String?, 157 + ); 158 + } 159 + 160 + Future<ProfileConnectionsPage> getFollowers({required String actor, String? cursor, int limit = 50}) async { 161 + final headers = _appViewContext.appBskyHeadersForEndpoint( 162 + 'app.bsky.graph.getFollowers', 163 + await _moderationService?.headersForRequest(), 164 + ); 165 + final response = await _authRecovery.run( 166 + (client) => client.graph.getFollowers(actor: actor, cursor: cursor, limit: limit, $headers: headers), 167 + ); 168 + final profiles = _filterProfileList(response.data.followers as List<ProfileView>); 169 + return ProfileConnectionsPage( 170 + subject: response.data.subject as ProfileView, 171 + profiles: profiles, 172 + cursor: response.data.cursor as String?, 173 + ); 141 174 } 142 175 143 176 /// Likes transport matrix: ··· 404 437 final map = reason is Map ? reason : (reason as dynamic).toJson(); 405 438 final indexedAt = map['indexedAt'] as String?; 406 439 return indexedAt == null ? null : DateTime.tryParse(indexedAt); 407 - } catch (_) { 440 + } catch (error, stackTrace) { 441 + log.d('ProfileRepository: ignored malformed actor likes reason', error: error, stackTrace: stackTrace); 408 442 return null; 409 443 } 410 444 } 445 + 446 + List<ProfileView> _filterProfileList(List<ProfileView> profiles) { 447 + final moderationService = _moderationService; 448 + if (moderationService == null) return profiles; 449 + return profiles.where((profile) => !moderationService.shouldFilterProfileInList(profile)).toList(growable: false); 450 + } 451 + } 452 + 453 + class ProfileConnectionsPage { 454 + const ProfileConnectionsPage({required this.subject, required this.profiles, this.cursor}); 455 + 456 + final ProfileView subject; 457 + final List<ProfileView> profiles; 458 + final String? cursor; 411 459 } 412 460 413 461 class ProfileActorLikesResult {
+494
lib/features/profile/presentation/profile_connections_screen.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:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/core/theme/spacing.dart'; 6 + import 'package:lazurite/core/theme/theme_extensions.dart'; 7 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 8 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 9 + import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 10 + import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; 11 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 12 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 13 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 16 + import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 17 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 18 + 19 + class ProfileConnectionsScreen extends StatefulWidget { 20 + const ProfileConnectionsScreen({ 21 + super.key, 22 + required this.actor, 23 + this.handle, 24 + this.initialTab = ProfileConnectionsTab.following, 25 + }); 26 + 27 + final String actor; 28 + final String? handle; 29 + final ProfileConnectionsTab initialTab; 30 + 31 + @override 32 + State<ProfileConnectionsScreen> createState() => _ProfileConnectionsScreenState(); 33 + } 34 + 35 + class _ProfileConnectionsScreenState extends State<ProfileConnectionsScreen> with SingleTickerProviderStateMixin { 36 + late final TabController _tabController; 37 + 38 + @override 39 + void initState() { 40 + super.initState(); 41 + _tabController = TabController( 42 + length: ProfileConnectionsTab.values.length, 43 + initialIndex: ProfileConnectionsTab.values.indexOf(widget.initialTab), 44 + vsync: this, 45 + )..addListener(_loadSelectedTab); 46 + WidgetsBinding.instance.addPostFrameCallback((_) => _loadSelectedTab()); 47 + } 48 + 49 + @override 50 + void dispose() { 51 + _tabController.removeListener(_loadSelectedTab); 52 + _tabController.dispose(); 53 + super.dispose(); 54 + } 55 + 56 + void _loadSelectedTab() { 57 + if (!mounted || _tabController.indexIsChanging) { 58 + return; 59 + } 60 + final tab = ProfileConnectionsTab.values[_tabController.index]; 61 + final cubit = context.read<ProfileConnectionsCubit>(); 62 + cubit.loadTab(tab); 63 + cubit.ensureSearchForTab(tab); 64 + } 65 + 66 + @override 67 + Widget build(BuildContext context) { 68 + final subtitle = widget.handle?.trim(); 69 + return Scaffold( 70 + appBar: AppBar( 71 + title: Text(subtitle == null || subtitle.isEmpty ? 'Connections' : '@$subtitle'), 72 + bottom: TabBar( 73 + controller: _tabController, 74 + onTap: (index) { 75 + final tab = ProfileConnectionsTab.values[index]; 76 + final cubit = context.read<ProfileConnectionsCubit>(); 77 + cubit.loadTab(tab); 78 + cubit.ensureSearchForTab(tab); 79 + }, 80 + tabs: const [ 81 + Tab(text: 'Following'), 82 + Tab(text: 'Followers'), 83 + Tab(text: 'Mutuals'), 84 + ], 85 + ), 86 + ), 87 + body: Column( 88 + children: [ 89 + _ConnectionsSearchField(activeTab: () => ProfileConnectionsTab.values[_tabController.index]), 90 + Expanded( 91 + child: TabBarView( 92 + controller: _tabController, 93 + children: const [ 94 + _ConnectionsTabView(tab: ProfileConnectionsTab.following), 95 + _ConnectionsTabView(tab: ProfileConnectionsTab.followers), 96 + _ConnectionsTabView(tab: ProfileConnectionsTab.mutuals), 97 + ], 98 + ), 99 + ), 100 + ], 101 + ), 102 + ); 103 + } 104 + } 105 + 106 + class _ConnectionsSearchField extends StatefulWidget { 107 + const _ConnectionsSearchField({required this.activeTab}); 108 + 109 + final ValueGetter<ProfileConnectionsTab> activeTab; 110 + 111 + @override 112 + State<_ConnectionsSearchField> createState() => _ConnectionsSearchFieldState(); 113 + } 114 + 115 + class _ConnectionsSearchFieldState extends State<_ConnectionsSearchField> { 116 + final TextEditingController _controller = TextEditingController(); 117 + 118 + @override 119 + void dispose() { 120 + _controller.dispose(); 121 + super.dispose(); 122 + } 123 + 124 + @override 125 + Widget build(BuildContext context) { 126 + return Padding( 127 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), 128 + child: TextField( 129 + key: const ValueKey('profile_connections_search_field'), 130 + controller: _controller, 131 + onChanged: (query) => context.read<ProfileConnectionsCubit>().setSearchQuery(query, widget.activeTab()), 132 + textInputAction: TextInputAction.search, 133 + decoration: InputDecoration( 134 + hintText: 'Search handle, name, or description', 135 + prefixIcon: const Icon(Icons.search), 136 + suffixIcon: BlocBuilder<ProfileConnectionsCubit, ProfileConnectionsState>( 137 + buildWhen: (previous, current) => previous.searchQuery != current.searchQuery, 138 + builder: (context, state) { 139 + if (state.searchQuery.isEmpty) { 140 + return const SizedBox.shrink(); 141 + } 142 + return IconButton( 143 + tooltip: 'Clear search', 144 + icon: const Icon(Icons.close), 145 + onPressed: () { 146 + _controller.clear(); 147 + context.read<ProfileConnectionsCubit>().setSearchQuery('', widget.activeTab()); 148 + FocusScope.of(context).unfocus(); 149 + }, 150 + ); 151 + }, 152 + ), 153 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), 154 + ), 155 + ), 156 + ); 157 + } 158 + } 159 + 160 + class _ConnectionsTabView extends StatelessWidget { 161 + const _ConnectionsTabView({required this.tab}); 162 + 163 + final ProfileConnectionsTab tab; 164 + 165 + @override 166 + Widget build(BuildContext context) { 167 + return BlocBuilder<ProfileConnectionsCubit, ProfileConnectionsState>( 168 + buildWhen: (previous, current) => 169 + previous.searchQuery != current.searchQuery || previous.dataFor(tab) != current.dataFor(tab), 170 + builder: (context, state) { 171 + final data = state.dataFor(tab); 172 + if (data.isLoading && data.profiles.isEmpty) { 173 + return LoadingState(message: 'Loading ${tab.title.toLowerCase()}...'); 174 + } 175 + 176 + if (data.hasError && data.profiles.isEmpty) { 177 + return ErrorState( 178 + title: 'Unable to load ${tab.title.toLowerCase()}', 179 + message: data.errorMessage ?? 'Unknown error', 180 + onRetry: () => context.read<ProfileConnectionsCubit>().loadTab(tab, force: true), 181 + ); 182 + } 183 + 184 + final profiles = state.visibleProfilesFor(tab); 185 + final isSearchMode = state.searchQuery.isNotEmpty; 186 + if (profiles.isEmpty) { 187 + if (isSearchMode && data.isSearching) { 188 + return LoadingState(message: 'Searching ${data.searchedCount} accounts...'); 189 + } 190 + 191 + final message = state.searchQuery.isEmpty 192 + ? 'No ${tab.title.toLowerCase()} found' 193 + : 'No ${tab.title.toLowerCase()} match "${state.searchQuery}"'; 194 + return EmptyState(message: message, icon: Icons.person_search_outlined); 195 + } 196 + 197 + return RefreshIndicator( 198 + onRefresh: () => context.read<ProfileConnectionsCubit>().refreshTab(tab), 199 + child: ListView.separated( 200 + padding: const EdgeInsets.fromLTRB(12, 4, 12, 24), 201 + itemCount: profiles.length + (isSearchMode || data.hasMore ? 1 : 0), 202 + separatorBuilder: (_, _) => const SizedBox(height: 8), 203 + itemBuilder: (context, index) { 204 + if (index >= profiles.length) { 205 + if (isSearchMode) { 206 + return _SearchProgressFooter(data: data); 207 + } 208 + return _LoadMoreFooter(tab: tab, data: data); 209 + } 210 + return _ConnectionProfileTile(profile: profiles[index]); 211 + }, 212 + ), 213 + ); 214 + }, 215 + ); 216 + } 217 + } 218 + 219 + class _ConnectionProfileTile extends StatelessWidget { 220 + const _ConnectionProfileTile({required this.profile}); 221 + 222 + final ProfileView profile; 223 + 224 + @override 225 + Widget build(BuildContext context) { 226 + final repository = _readActionRepository(context); 227 + final currentUserDid = _readCurrentUserDid(context); 228 + if (repository == null || profile.did == currentUserDid) { 229 + return _ConnectionProfileTileBody( 230 + profile: profile, 231 + trailing: profile.did == currentUserDid ? const _YouPill() : null, 232 + ); 233 + } 234 + 235 + return BlocProvider( 236 + create: (_) => ProfileActionCubit( 237 + profileActionRepository: repository, 238 + actorDid: profile.did, 239 + isFollowing: profile.viewer?.following != null, 240 + isMuted: profile.viewer?.muted ?? false, 241 + isBlocked: profile.viewer?.blocking != null, 242 + isBlockedBy: profile.viewer?.blockedBy ?? false, 243 + followUri: profile.viewer?.following?.toString(), 244 + blockUri: profile.viewer?.blocking?.toString(), 245 + ), 246 + child: BlocConsumer<ProfileActionCubit, ProfileActionState>( 247 + listener: (context, state) { 248 + if (state.error == null) { 249 + return; 250 + } 251 + showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); 252 + context.read<ProfileActionCubit>().clearError(); 253 + }, 254 + builder: (context, state) { 255 + return _ConnectionProfileTileBody( 256 + profile: profile, 257 + trailing: _InlineFollowButton( 258 + isFollowing: state.isFollowing, 259 + isLoading: state.isLoadingFollow, 260 + isBlockedBy: state.isBlockedBy, 261 + onPressed: () => context.read<ProfileActionCubit>().toggleFollow(), 262 + ), 263 + ); 264 + }, 265 + ), 266 + ); 267 + } 268 + 269 + ProfileActionRepository? _readActionRepository(BuildContext context) { 270 + try { 271 + return context.read<ProfileActionRepository>(); 272 + } catch (error, stackTrace) { 273 + log.d('ProfileConnectionsScreen: ProfileActionRepository unavailable', error: error, stackTrace: stackTrace); 274 + return null; 275 + } 276 + } 277 + 278 + String? _readCurrentUserDid(BuildContext context) { 279 + try { 280 + return context.read<String>(); 281 + } catch (error, stackTrace) { 282 + log.d('ProfileConnectionsScreen: current user DID unavailable', error: error, stackTrace: stackTrace); 283 + return null; 284 + } 285 + } 286 + } 287 + 288 + class _ConnectionProfileTileBody extends StatelessWidget { 289 + const _ConnectionProfileTileBody({required this.profile, this.trailing}); 290 + 291 + final ProfileView profile; 292 + final Widget? trailing; 293 + 294 + @override 295 + Widget build(BuildContext context) { 296 + final title = profile.displayName?.trim().isNotEmpty == true ? profile.displayName!.trim() : profile.handle; 297 + final description = profile.description?.trim(); 298 + final joined = profile.createdAt == null ? null : 'Joined ${_formatJoinedAgo(profile.createdAt!)}'; 299 + final metaStyle = context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant); 300 + 301 + return Material( 302 + color: context.colorScheme.surfaceContainerLow, 303 + borderRadius: BorderRadius.circular(8), 304 + child: InkWell( 305 + borderRadius: BorderRadius.circular(8), 306 + onTap: () => navigateToProfile(context, profile.did), 307 + child: Padding( 308 + padding: const EdgeInsets.all(12), 309 + child: Row( 310 + crossAxisAlignment: CrossAxisAlignment.start, 311 + children: [ 312 + ProfileAvatar(size: 44, imageUrl: profile.avatar, fallbackText: title), 313 + const SizedBox(width: AppSpacing.md), 314 + Expanded( 315 + child: Column( 316 + crossAxisAlignment: CrossAxisAlignment.start, 317 + children: [ 318 + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.titleSmall), 319 + const SizedBox(height: 2), 320 + Text('@${profile.handle}', maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), 321 + if (description != null && description.isNotEmpty) ...[ 322 + const SizedBox(height: 8), 323 + Text( 324 + description, 325 + maxLines: 2, 326 + overflow: TextOverflow.ellipsis, 327 + style: context.textTheme.bodyMedium, 328 + ), 329 + ], 330 + if (joined != null) ...[const SizedBox(height: 8), Text(joined, style: metaStyle)], 331 + ], 332 + ), 333 + ), 334 + if (trailing != null) ...[const SizedBox(width: AppSpacing.sm), trailing!], 335 + ], 336 + ), 337 + ), 338 + ), 339 + ); 340 + } 341 + } 342 + 343 + class _InlineFollowButton extends StatelessWidget { 344 + const _InlineFollowButton({ 345 + required this.isFollowing, 346 + required this.isLoading, 347 + required this.isBlockedBy, 348 + required this.onPressed, 349 + }); 350 + 351 + final bool isFollowing; 352 + final bool isLoading; 353 + final bool isBlockedBy; 354 + final VoidCallback onPressed; 355 + 356 + @override 357 + Widget build(BuildContext context) { 358 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 359 + if (isLoading) { 360 + return const SizedBox(width: 32, height: 32, child: CircularProgressIndicator(strokeWidth: 2)); 361 + } 362 + 363 + final effectiveOnPressed = isOffline || isBlockedBy ? null : onPressed; 364 + final button = isFollowing 365 + ? OutlinedButton(onPressed: effectiveOnPressed, child: const Text('Following')) 366 + : FilledButton.tonal(onPressed: effectiveOnPressed, child: const Text('Follow')); 367 + 368 + if (!isOffline) { 369 + return button; 370 + } 371 + return Tooltip(message: offlineActionMessage('change your follow state'), child: button); 372 + } 373 + } 374 + 375 + class _YouPill extends StatelessWidget { 376 + const _YouPill(); 377 + 378 + @override 379 + Widget build(BuildContext context) { 380 + return Chip( 381 + visualDensity: VisualDensity.compact, 382 + side: BorderSide(color: context.colorScheme.outlineVariant), 383 + label: const Text('You'), 384 + ); 385 + } 386 + } 387 + 388 + class _LoadMoreFooter extends StatelessWidget { 389 + const _LoadMoreFooter({required this.tab, required this.data}); 390 + 391 + final ProfileConnectionsTab tab; 392 + final ProfileConnectionsTabData data; 393 + 394 + @override 395 + Widget build(BuildContext context) { 396 + final errorMessage = data.loadMoreErrorMessage; 397 + return Center( 398 + child: Padding( 399 + padding: const EdgeInsets.symmetric(vertical: 8), 400 + child: data.isLoadingMore 401 + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) 402 + : Column( 403 + mainAxisSize: MainAxisSize.min, 404 + children: [ 405 + if (errorMessage != null) ...[ 406 + Padding( 407 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 408 + child: Text( 409 + errorMessage, 410 + textAlign: TextAlign.center, 411 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error), 412 + ), 413 + ), 414 + ], 415 + OutlinedButton( 416 + onPressed: () => context.read<ProfileConnectionsCubit>().loadMore(tab), 417 + child: Text(errorMessage == null ? 'Load more' : 'Retry'), 418 + ), 419 + ], 420 + ), 421 + ), 422 + ); 423 + } 424 + } 425 + 426 + class _SearchProgressFooter extends StatelessWidget { 427 + const _SearchProgressFooter({required this.data}); 428 + 429 + final ProfileConnectionsTabData data; 430 + 431 + @override 432 + Widget build(BuildContext context) { 433 + final colorScheme = context.colorScheme; 434 + final textTheme = context.textTheme; 435 + final message = switch (data.searchStatus) { 436 + ProfileConnectionsSearchStatus.searching => 'Searching ${data.searchedCount} accounts...', 437 + ProfileConnectionsSearchStatus.complete => 'Searched ${data.searchedCount} accounts', 438 + ProfileConnectionsSearchStatus.error => 'Search stopped after ${data.searchedCount} accounts', 439 + ProfileConnectionsSearchStatus.idle => '', 440 + }; 441 + 442 + if (message.isEmpty) { 443 + return const SizedBox.shrink(); 444 + } 445 + 446 + return Padding( 447 + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), 448 + child: Row( 449 + mainAxisAlignment: MainAxisAlignment.center, 450 + children: [ 451 + if (data.searchStatus == ProfileConnectionsSearchStatus.searching) ...[ 452 + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), 453 + const SizedBox(width: AppSpacing.xs), 454 + ], 455 + Flexible( 456 + child: Text( 457 + data.searchErrorMessage ?? message, 458 + textAlign: TextAlign.center, 459 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 460 + ), 461 + ), 462 + ], 463 + ), 464 + ); 465 + } 466 + } 467 + 468 + String _formatJoinedAgo(DateTime joinedAt) { 469 + final now = DateTime.now(); 470 + var difference = now.difference(joinedAt); 471 + if (difference.isNegative) { 472 + difference = Duration.zero; 473 + } 474 + 475 + if (difference.inDays >= 365) { 476 + return '${difference.inDays ~/ 365}y ago'; 477 + } 478 + if (difference.inDays >= 30) { 479 + return '${difference.inDays ~/ 30}mo ago'; 480 + } 481 + if (difference.inDays >= 7) { 482 + return '${difference.inDays ~/ 7}w ago'; 483 + } 484 + if (difference.inDays >= 1) { 485 + return '${difference.inDays}d ago'; 486 + } 487 + if (difference.inHours >= 1) { 488 + return '${difference.inHours}h ago'; 489 + } 490 + if (difference.inMinutes >= 1) { 491 + return '${difference.inMinutes}m ago'; 492 + } 493 + return 'now'; 494 + }
+63 -12
lib/features/profile/presentation/profile_screen.dart
··· 8 8 import 'package:flutter_bloc/flutter_bloc.dart'; 9 9 import 'package:go_router/go_router.dart'; 10 10 import 'package:intl/intl.dart'; 11 + import 'package:lazurite/core/logging/app_logger.dart'; 11 12 import 'package:lazurite/core/network/app_view_provider.dart'; 12 13 import 'package:lazurite/core/network/app_view_web_links.dart'; 13 14 import 'package:lazurite/core/router/app_shell.dart'; ··· 34 35 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 35 36 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 36 37 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 38 + import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; 37 39 import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 38 40 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 39 41 import 'package:lazurite/features/profile/data/profile_repository.dart'; ··· 409 411 410 412 try { 411 413 await Future.wait(futures); 412 - } catch (_) {} 414 + } catch (error, stackTrace) { 415 + log.d('ProfileScreen: ignored jump-to-top scroll animation error', error: error, stackTrace: stackTrace); 416 + } 413 417 } 414 418 415 419 bool get _isAtTop => !_profileScrollController.hasClients || _profileScrollController.position.pixels <= 0.5; ··· 831 835 padding: const EdgeInsets.symmetric(vertical: 12), 832 836 child: Row( 833 837 children: [ 834 - _buildStat(context, profile.followsCount ?? 0, 'Following'), 838 + _buildStat( 839 + context, 840 + profile.followsCount ?? 0, 841 + 'Following', 842 + key: const ValueKey('profile_following_stat'), 843 + onTap: () => _openConnections(context, profile, ProfileConnectionsTab.following), 844 + ), 835 845 const SizedBox(width: 24), 836 - _buildStat(context, profile.followersCount ?? 0, 'Followers'), 846 + _buildStat( 847 + context, 848 + profile.followersCount ?? 0, 849 + 'Followers', 850 + key: const ValueKey('profile_followers_stat'), 851 + onTap: () => _openConnections(context, profile, ProfileConnectionsTab.followers), 852 + ), 837 853 const SizedBox(width: 24), 838 854 _buildStat(context, profile.postsCount ?? 0, 'Posts'), 839 855 ], ··· 890 906 return InkWell(onTap: onTap, borderRadius: BorderRadius.circular(999), child: chip); 891 907 } 892 908 893 - Widget _buildStat(BuildContext context, int count, String label) { 909 + Widget _buildStat(BuildContext context, int count, String label, {Key? key, VoidCallback? onTap}) { 894 910 final colorScheme = context.colorScheme; 895 - return Column( 911 + final child = Column( 896 912 crossAxisAlignment: CrossAxisAlignment.start, 897 913 children: [ 898 914 Text(formatCount(count), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), ··· 902 918 ), 903 919 ], 904 920 ); 921 + 922 + if (onTap == null) { 923 + return KeyedSubtree(key: key, child: child); 924 + } 925 + return InkWell( 926 + key: key, 927 + onTap: onTap, 928 + borderRadius: BorderRadius.circular(8), 929 + child: Padding(padding: AppInsets.allXs, child: child), 930 + ); 931 + } 932 + 933 + void _openConnections(BuildContext context, ProfileViewDetailed profile, ProfileConnectionsTab tab) { 934 + final routeActor = profile.handle.trim().isNotEmpty ? profile.handle.trim() : profile.did; 935 + final encodedActor = Uri.encodeComponent(routeActor); 936 + context.push('/profile/$encodedActor/connections?tab=${tab.routeValue}'); 905 937 } 906 938 907 939 Widget _buildProfileActions(BuildContext context, ProfileViewDetailed profile) { ··· 1013 1045 ListRepository? listRepository; 1014 1046 try { 1015 1047 listRepository = context.read<ListRepository>(); 1016 - } catch (_) { 1048 + } catch (error, stackTrace) { 1049 + log.d('ProfileScreen: ListRepository unavailable for add-to-list sheet', error: error, stackTrace: stackTrace); 1017 1050 return; 1018 1051 } 1019 1052 ··· 1086 1119 String _resolveAppViewProvider(BuildContext context) { 1087 1120 try { 1088 1121 return context.read<SettingsCubit>().state.appViewProvider; 1089 - } catch (_) { 1122 + } catch (error, stackTrace) { 1123 + log.d( 1124 + 'ProfileScreen: SettingsCubit unavailable; using default AppView provider', 1125 + error: error, 1126 + stackTrace: stackTrace, 1127 + ); 1090 1128 return AppViewProviders.defaultKey; 1091 1129 } 1092 1130 } ··· 1095 1133 ProfileRepository? profileRepository; 1096 1134 try { 1097 1135 profileRepository = context.read<ProfileRepository>(); 1098 - } catch (_) { 1136 + } catch (error, stackTrace) { 1137 + log.d( 1138 + 'ProfileScreen: ProfileRepository unavailable for suggested follows sheet', 1139 + error: error, 1140 + stackTrace: stackTrace, 1141 + ); 1099 1142 return; 1100 1143 } 1101 1144 ··· 1372 1415 ListRepository? listRepository; 1373 1416 try { 1374 1417 listRepository = context.read<ListRepository>(); 1375 - } catch (_) { 1418 + } catch (error, stackTrace) { 1419 + log.d('ProfileScreen: ListRepository unavailable for lists tab', error: error, stackTrace: stackTrace); 1376 1420 return const SizedBox.shrink(); 1377 1421 } 1378 1422 ··· 1386 1430 ProfileRepository? profileRepository; 1387 1431 try { 1388 1432 profileRepository = context.read<ProfileRepository>(); 1389 - } catch (_) { 1433 + } catch (error, stackTrace) { 1434 + log.d('ProfileScreen: ProfileRepository unavailable for liked posts tab', error: error, stackTrace: stackTrace); 1390 1435 return const SizedBox.shrink(); 1391 1436 } 1392 1437 ··· 1400 1445 StarterPackRepository? starterPackRepository; 1401 1446 try { 1402 1447 starterPackRepository = context.read<StarterPackRepository>(); 1403 - } catch (_) { 1448 + } catch (error, stackTrace) { 1449 + log.d( 1450 + 'ProfileScreen: StarterPackRepository unavailable for starter packs tab', 1451 + error: error, 1452 + stackTrace: stackTrace, 1453 + ); 1404 1454 return const SizedBox.shrink(); 1405 1455 } 1406 1456 ··· 1507 1557 try { 1508 1558 final repository = context.read<ProfileRepository>(); 1509 1559 return SuggestedFollowsCubit(repository: repository)..load(actor); 1510 - } catch (_) { 1560 + } catch (error, stackTrace) { 1561 + log.d('ProfileScreen: ProfileRepository unavailable for suggested tab', error: error, stackTrace: stackTrace); 1511 1562 return null; 1512 1563 } 1513 1564 }
+8
pubspec.lock
··· 688 688 url: "https://pub.dev" 689 689 source: hosted 690 690 version: "4.0.0" 691 + fuzzywuzzy: 692 + dependency: "direct main" 693 + description: 694 + name: fuzzywuzzy 695 + sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c" 696 + url: "https://pub.dev" 697 + source: hosted 698 + version: "1.2.0" 691 699 gal: 692 700 dependency: "direct main" 693 701 description:
+1
pubspec.yaml
··· 57 57 firebase_core: ^4.0.0 58 58 firebase_messaging: ^16.0.0 59 59 firebase_crashlytics: ^5.2.0 60 + fuzzywuzzy: ^1.2.0 60 61 61 62 dev_dependencies: 62 63 flutter_test:
+2
test/core/network/app_bsky_routing_policy_test.dart
··· 8 8 'app.bsky.actor.getProfile', 9 9 'app.bsky.actor.getProfiles', 10 10 'app.bsky.actor.searchActorsTypeahead', 11 + 'app.bsky.graph.getFollowers', 12 + 'app.bsky.graph.getFollows', 11 13 'app.bsky.graph.getList', 12 14 'app.bsky.graph.getLists', 13 15 'app.bsky.feed.getActorLikes',
+14
test/core/network/constellation_client_test.dart
··· 283 283 expect(capturedUri?.queryParameters['cursor'], 'page2'); 284 284 }); 285 285 286 + test('passes repeated did filters as query parameters', () async { 287 + Uri? capturedUri; 288 + final client = ConstellationClient( 289 + httpClient: MockClient((request) async { 290 + capturedUri = request.url; 291 + return http.Response(jsonEncode({'total': 0, 'records': []}), 200); 292 + }), 293 + ); 294 + 295 + await client.getBacklinks('did:plc:abc', 'app.bsky.graph.follow:subject', dids: ['did:plc:one', 'did:plc:two']); 296 + 297 + expect(capturedUri?.queryParametersAll['did'], ['did:plc:one', 'did:plc:two']); 298 + }); 299 + 286 300 test('throws ConstellationException on error response', () async { 287 301 final client = ConstellationClient(httpClient: MockClient((_) async => http.Response('Unauthorized', 401))); 288 302
+27
test/core/router/app_router_test.dart
··· 20 20 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 21 21 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 22 22 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 23 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 23 24 import 'package:lazurite/features/search/data/search_repository.dart'; 24 25 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 25 26 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 46 47 47 48 class MockNotificationRepository extends Mock implements NotificationRepository {} 48 49 50 + class MockProfileRepository extends Mock implements ProfileRepository {} 51 + 49 52 class MockSearchRepository extends Mock implements SearchRepository {} 50 53 51 54 class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} ··· 63 66 late MockUnreadCountCubit unreadCountCubit; 64 67 late MockConvoListBloc convoListBloc; 65 68 late MockNotificationRepository notificationRepository; 69 + late MockProfileRepository profileRepository; 66 70 late MockSearchRepository searchRepository; 67 71 late MockTypeaheadRepository typeaheadRepository; 68 72 late MockAppDatabase database; ··· 102 106 unreadCountCubit = MockUnreadCountCubit(); 103 107 convoListBloc = MockConvoListBloc(); 104 108 notificationRepository = MockNotificationRepository(); 109 + profileRepository = MockProfileRepository(); 105 110 searchRepository = MockSearchRepository(); 106 111 typeaheadRepository = MockTypeaheadRepository(); 107 112 database = MockAppDatabase(); ··· 197 202 RepositoryProvider<SearchRepository>.value(value: searchRepository), 198 203 RepositoryProvider<TypeaheadRepository>.value(value: typeaheadRepository), 199 204 RepositoryProvider<AppDatabase>.value(value: database), 205 + RepositoryProvider<ProfileRepository>.value(value: profileRepository), 200 206 RepositoryProvider<String>.value(value: tokens.did), 201 207 ], 202 208 child: MaterialApp.router(routerConfig: router), ··· 302 308 await tester.pumpAndSettle(); 303 309 304 310 expect(find.text('Search @me.bsky.social'), findsOneWidget); 311 + }); 312 + 313 + testWidgets('opens profile connections route with requested initial tab', (tester) async { 314 + await tester.binding.setSurfaceSize(const Size(430, 932)); 315 + addTearDown(() => tester.binding.setSurfaceSize(null)); 316 + when(() => profileRepository.getFollowers(actor: tokens.handle, cursor: null, limit: 100)).thenAnswer( 317 + (_) async => const ProfileConnectionsPage( 318 + subject: ProfileView(did: 'did:plc:me', handle: 'me.bsky.social'), 319 + profiles: [], 320 + ), 321 + ); 322 + final router = AppRouter(authBloc: authBloc).router; 323 + 324 + await tester.pumpWidget(buildSubjectWithRouter(router)); 325 + router.go('/profile/${Uri.encodeComponent(tokens.handle)}/connections?tab=followers'); 326 + await tester.pumpAndSettle(); 327 + 328 + expect(find.text('@me.bsky.social'), findsOneWidget); 329 + verify(() => profileRepository.getFollowers(actor: tokens.handle, cursor: null, limit: 100)).called(1); 330 + 331 + router.dispose(); 305 332 }); 306 333 307 334 testWidgets('Android back at non-Home tab root switches to Home tab', (tester) async {
+233
test/features/profile/cubit/profile_connections_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/network/constellation_client.dart'; 5 + import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; 6 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockProfileRepository extends Mock implements ProfileRepository {} 10 + 11 + class MockConstellationClient extends Mock implements ConstellationClient {} 12 + 13 + void main() { 14 + late MockProfileRepository repository; 15 + late MockConstellationClient constellationClient; 16 + 17 + const subject = ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'); 18 + const astronaut = ProfileView( 19 + did: 'did:plc:astro', 20 + handle: 'astro.bsky.social', 21 + displayName: 'Lina Orbit', 22 + description: 'Space systems engineer', 23 + ); 24 + const gardener = ProfileView( 25 + did: 'did:plc:garden', 26 + handle: 'garden.bsky.social', 27 + displayName: 'Moss Vale', 28 + description: 'Native plant notes', 29 + ); 30 + 31 + setUp(() { 32 + repository = MockProfileRepository(); 33 + constellationClient = MockConstellationClient(); 34 + }); 35 + 36 + group('ProfileConnectionsCubit', () { 37 + blocTest<ProfileConnectionsCubit, ProfileConnectionsState>( 38 + 'loads following through the repository', 39 + build: () { 40 + when(() => repository.getFollowing(actor: 'did:plc:alice', cursor: null, limit: 100)).thenAnswer( 41 + (_) async => const ProfileConnectionsPage(subject: subject, profiles: [astronaut], cursor: 'next'), 42 + ); 43 + return ProfileConnectionsCubit(repository: repository, actor: 'did:plc:alice'); 44 + }, 45 + act: (cubit) => cubit.loadTab(ProfileConnectionsTab.following), 46 + expect: () => [ 47 + isA<ProfileConnectionsState>().having( 48 + (state) => state.following.status, 49 + 'following.status', 50 + ProfileConnectionsStatus.loading, 51 + ), 52 + isA<ProfileConnectionsState>() 53 + .having((state) => state.following.status, 'following.status', ProfileConnectionsStatus.loaded) 54 + .having((state) => state.following.profiles, 'following.profiles', [astronaut]) 55 + .having((state) => state.following.cursor, 'following.cursor', 'next'), 56 + ], 57 + ); 58 + 59 + blocTest<ProfileConnectionsCubit, ProfileConnectionsState>( 60 + 'loads more followers using the stored cursor', 61 + build: () { 62 + when( 63 + () => repository.getFollowers(actor: 'did:plc:alice', cursor: 'next', limit: 100), 64 + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [gardener])); 65 + return ProfileConnectionsCubit(repository: repository, actor: 'did:plc:alice'); 66 + }, 67 + seed: () => const ProfileConnectionsState( 68 + followers: ProfileConnectionsTabData( 69 + status: ProfileConnectionsStatus.loaded, 70 + profiles: [astronaut], 71 + cursor: 'next', 72 + ), 73 + ), 74 + act: (cubit) => cubit.loadMore(ProfileConnectionsTab.followers), 75 + expect: () => [ 76 + isA<ProfileConnectionsState>().having((state) => state.followers.isLoadingMore, 'isLoadingMore', isTrue), 77 + isA<ProfileConnectionsState>() 78 + .having((state) => state.followers.isLoadingMore, 'isLoadingMore', isFalse) 79 + .having((state) => state.followers.profiles, 'followers.profiles', [astronaut, gardener]) 80 + .having((state) => state.followers.cursor, 'followers.cursor', isNull), 81 + ], 82 + ); 83 + 84 + blocTest<ProfileConnectionsCubit, ProfileConnectionsState>( 85 + 'stores load-more failures separately while keeping loaded profiles', 86 + build: () { 87 + when( 88 + () => repository.getFollowers(actor: 'did:plc:alice', cursor: 'next', limit: 100), 89 + ).thenThrow(Exception('network down')); 90 + return ProfileConnectionsCubit(repository: repository, actor: 'did:plc:alice'); 91 + }, 92 + seed: () => const ProfileConnectionsState( 93 + followers: ProfileConnectionsTabData( 94 + status: ProfileConnectionsStatus.loaded, 95 + profiles: [astronaut], 96 + cursor: 'next', 97 + ), 98 + ), 99 + act: (cubit) => cubit.loadMore(ProfileConnectionsTab.followers), 100 + expect: () => [ 101 + isA<ProfileConnectionsState>() 102 + .having((state) => state.followers.status, 'followers.status', ProfileConnectionsStatus.loaded) 103 + .having((state) => state.followers.isLoadingMore, 'isLoadingMore', isTrue) 104 + .having((state) => state.followers.loadMoreErrorMessage, 'loadMoreErrorMessage', isNull), 105 + isA<ProfileConnectionsState>() 106 + .having((state) => state.followers.status, 'followers.status', ProfileConnectionsStatus.loaded) 107 + .having((state) => state.followers.isLoadingMore, 'isLoadingMore', isFalse) 108 + .having((state) => state.followers.profiles, 'followers.profiles', [astronaut]) 109 + .having((state) => state.followers.loadMoreErrorMessage, 'loadMoreErrorMessage', contains('network down')), 110 + ], 111 + ); 112 + 113 + test('visible profiles use progressive search results when a query is active', () { 114 + final state = const ProfileConnectionsState( 115 + following: ProfileConnectionsTabData( 116 + status: ProfileConnectionsStatus.loaded, 117 + profiles: [gardener], 118 + searchStatus: ProfileConnectionsSearchStatus.complete, 119 + searchQuery: 'space engineer', 120 + searchResults: [astronaut], 121 + searchedCount: 2, 122 + ), 123 + ).copyWith(searchQuery: 'space engineer'); 124 + 125 + expect(state.visibleProfilesFor(ProfileConnectionsTab.following), [astronaut]); 126 + }); 127 + 128 + blocTest<ProfileConnectionsCubit, ProfileConnectionsState>( 129 + 'progressively searches every API page using limit 100', 130 + build: () { 131 + when( 132 + () => repository.getFollowing(actor: 'did:plc:alice', cursor: null, limit: 100), 133 + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [gardener], cursor: 'next')); 134 + when( 135 + () => repository.getFollowing(actor: 'did:plc:alice', cursor: 'next', limit: 100), 136 + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [astronaut])); 137 + return ProfileConnectionsCubit(repository: repository, actor: 'did:plc:alice'); 138 + }, 139 + act: (cubit) { 140 + cubit.setSearchQuery('space engineer', ProfileConnectionsTab.following); 141 + }, 142 + wait: const Duration(milliseconds: 350), 143 + expect: () => [ 144 + isA<ProfileConnectionsState>().having((state) => state.searchQuery, 'searchQuery', 'space engineer'), 145 + isA<ProfileConnectionsState>().having( 146 + (state) => state.following.searchStatus, 147 + 'following.searchStatus', 148 + ProfileConnectionsSearchStatus.searching, 149 + ), 150 + isA<ProfileConnectionsState>() 151 + .having( 152 + (state) => state.following.searchStatus, 153 + 'following.searchStatus', 154 + ProfileConnectionsSearchStatus.searching, 155 + ) 156 + .having((state) => state.following.searchResults, 'following.searchResults', isEmpty) 157 + .having((state) => state.following.searchedCount, 'following.searchedCount', 1), 158 + isA<ProfileConnectionsState>() 159 + .having( 160 + (state) => state.following.searchStatus, 161 + 'following.searchStatus', 162 + ProfileConnectionsSearchStatus.searching, 163 + ) 164 + .having((state) => state.following.searchResults, 'following.searchResults', [astronaut]) 165 + .having((state) => state.following.searchedCount, 'following.searchedCount', 2), 166 + isA<ProfileConnectionsState>() 167 + .having( 168 + (state) => state.following.searchStatus, 169 + 'following.searchStatus', 170 + ProfileConnectionsSearchStatus.complete, 171 + ) 172 + .having((state) => state.following.searchResults, 'following.searchResults', [astronaut]) 173 + .having((state) => state.following.searchedCount, 'following.searchedCount', 2), 174 + ], 175 + verify: (_) { 176 + verify(() => repository.getFollowing(actor: 'did:plc:alice', cursor: null, limit: 100)).called(1); 177 + verify(() => repository.getFollowing(actor: 'did:plc:alice', cursor: 'next', limit: 100)).called(1); 178 + }, 179 + ); 180 + 181 + blocTest<ProfileConnectionsCubit, ProfileConnectionsState>( 182 + 'loads mutuals by checking followed accounts against Constellation backlinks', 183 + build: () { 184 + when( 185 + () => repository.getFollowing(actor: 'did:plc:alice', cursor: null, limit: 100), 186 + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [astronaut, gardener])); 187 + when( 188 + () => constellationClient.getBacklinks( 189 + subject.did, 190 + 'app.bsky.graph.follow:subject', 191 + limit: 100, 192 + cursor: null, 193 + dids: [astronaut.did, gardener.did], 194 + ), 195 + ).thenAnswer( 196 + (_) async => const ( 197 + total: 1, 198 + records: [ConstellationLinkRecord(did: 'did:plc:astro', collection: 'app.bsky.graph.follow', rkey: 'abc')], 199 + cursor: null, 200 + ), 201 + ); 202 + return ProfileConnectionsCubit( 203 + repository: repository, 204 + actor: 'did:plc:alice', 205 + constellationClient: constellationClient, 206 + ); 207 + }, 208 + act: (cubit) => cubit.loadTab(ProfileConnectionsTab.mutuals), 209 + expect: () => [ 210 + isA<ProfileConnectionsState>().having( 211 + (state) => state.mutuals.status, 212 + 'mutuals.status', 213 + ProfileConnectionsStatus.loading, 214 + ), 215 + isA<ProfileConnectionsState>() 216 + .having((state) => state.mutuals.status, 'mutuals.status', ProfileConnectionsStatus.loaded) 217 + .having((state) => state.mutuals.profiles, 'mutuals.profiles', [astronaut]) 218 + .having((state) => state.mutuals.cursor, 'mutuals.cursor', isNull), 219 + ], 220 + verify: (_) { 221 + verify( 222 + () => constellationClient.getBacklinks( 223 + subject.did, 224 + 'app.bsky.graph.follow:subject', 225 + limit: 100, 226 + cursor: null, 227 + dids: [astronaut.did, gardener.did], 228 + ), 229 + ).called(1); 230 + }, 231 + ); 232 + }); 233 + }
+114 -1
test/features/profile/data/profile_repository_test.dart
··· 67 67 }); 68 68 }); 69 69 70 + group('connections', () { 71 + test('returns following page from graph service', () async { 72 + const subject = ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'); 73 + const follows = [ 74 + ProfileView(did: 'did:plc:bob', handle: 'bob.bsky.social'), 75 + ProfileView(did: 'did:plc:carol', handle: 'carol.bsky.social'), 76 + ]; 77 + final repository = ProfileRepository( 78 + database: database, 79 + bluesky: _FakeBlueskyClient( 80 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 81 + graph: _FakeGraphService(follows: follows, followsSubject: subject, followsCursor: 'next'), 82 + ), 83 + ); 84 + 85 + final result = await repository.getFollowing(actor: subject.did, limit: 25); 86 + 87 + expect(result.subject, subject); 88 + expect(result.profiles, follows); 89 + expect(result.cursor, 'next'); 90 + }); 91 + 92 + test('returns followers page from graph service', () async { 93 + const subject = ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'); 94 + const followers = [ProfileView(did: 'did:plc:dana', handle: 'dana.bsky.social')]; 95 + final repository = ProfileRepository( 96 + database: database, 97 + bluesky: _FakeBlueskyClient( 98 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 99 + graph: _FakeGraphService(followers: followers, followersSubject: subject), 100 + ), 101 + ); 102 + 103 + final result = await repository.getFollowers(actor: subject.did, cursor: 'cursor'); 104 + 105 + expect(result.subject, subject); 106 + expect(result.profiles, followers); 107 + expect(result.cursor, isNull); 108 + }); 109 + }); 110 + 70 111 test('loads and caches a profile after a successful xrpc response', () async { 71 112 final profile = _buildProfile(); 72 113 final repository = ProfileRepository( ··· 242 283 _FakeGraphService({ 243 284 List<ProfileView>? suggestions, 244 285 Future<_FakeSuggestedResponse> Function(String actor)? onGetSuggested, 286 + List<ProfileView>? follows, 287 + ProfileView? followsSubject, 288 + String? followsCursor, 289 + List<ProfileView>? followers, 290 + ProfileView? followersSubject, 291 + String? followersCursor, 245 292 }) : _suggestions = suggestions ?? [], 246 - _onGetSuggested = onGetSuggested; 293 + _onGetSuggested = onGetSuggested, 294 + _follows = follows ?? [], 295 + _followsSubject = followsSubject, 296 + _followsCursor = followsCursor, 297 + _followers = followers ?? [], 298 + _followersSubject = followersSubject, 299 + _followersCursor = followersCursor; 247 300 248 301 final List<ProfileView> _suggestions; 249 302 final Future<_FakeSuggestedResponse> Function(String actor)? _onGetSuggested; 303 + final List<ProfileView> _follows; 304 + final ProfileView? _followsSubject; 305 + final String? _followsCursor; 306 + final List<ProfileView> _followers; 307 + final ProfileView? _followersSubject; 308 + final String? _followersCursor; 250 309 251 310 Future<_FakeSuggestedResponse> getSuggestedFollowsByActor({required String actor, Map<String, String>? $headers}) { 252 311 final handler = _onGetSuggested; 253 312 if (handler != null) return handler(actor); 254 313 return Future.value(_FakeSuggestedResponse(_FakeSuggestedData(_suggestions))); 255 314 } 315 + 316 + Future<_FakeFollowsResponse> getFollows({ 317 + required String actor, 318 + String? cursor, 319 + int? limit, 320 + Map<String, String>? $headers, 321 + }) { 322 + return Future.value( 323 + _FakeFollowsResponse( 324 + _FakeFollowsData(_followsSubject ?? ProfileView(did: actor, handle: actor), _follows, _followsCursor), 325 + ), 326 + ); 327 + } 328 + 329 + Future<_FakeFollowersResponse> getFollowers({ 330 + required String actor, 331 + String? cursor, 332 + int? limit, 333 + Map<String, String>? $headers, 334 + }) { 335 + return Future.value( 336 + _FakeFollowersResponse( 337 + _FakeFollowersData(_followersSubject ?? ProfileView(did: actor, handle: actor), _followers, _followersCursor), 338 + ), 339 + ); 340 + } 256 341 } 257 342 258 343 class _FakeSuggestedResponse { ··· 265 350 const _FakeSuggestedData(this.suggestions); 266 351 267 352 final List<ProfileView> suggestions; 353 + } 354 + 355 + class _FakeFollowsResponse { 356 + _FakeFollowsResponse(this.data); 357 + 358 + final _FakeFollowsData data; 359 + } 360 + 361 + class _FakeFollowsData { 362 + const _FakeFollowsData(this.subject, this.follows, this.cursor); 363 + 364 + final ProfileView subject; 365 + final List<ProfileView> follows; 366 + final String? cursor; 367 + } 368 + 369 + class _FakeFollowersResponse { 370 + _FakeFollowersResponse(this.data); 371 + 372 + final _FakeFollowersData data; 373 + } 374 + 375 + class _FakeFollowersData { 376 + const _FakeFollowersData(this.subject, this.followers, this.cursor); 377 + 378 + final ProfileView subject; 379 + final List<ProfileView> followers; 380 + final String? cursor; 268 381 } 269 382 270 383 atp_core.UnauthorizedException _unauthorizedException() {
+164
test/features/profile/presentation/profile_connections_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 8 + import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; 9 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 10 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 11 + import 'package:lazurite/features/profile/presentation/profile_connections_screen.dart'; 12 + import 'package:mocktail/mocktail.dart'; 13 + 14 + class MockProfileRepository extends Mock implements ProfileRepository {} 15 + 16 + class MockProfileActionRepository extends Mock implements ProfileActionRepository {} 17 + 18 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 19 + 20 + void main() { 21 + late MockProfileRepository profileRepository; 22 + late MockProfileActionRepository profileActionRepository; 23 + late MockConnectivityCubit connectivityCubit; 24 + 25 + const subject = ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'); 26 + final followingUri = AtUri.parse('at://did:plc:me/app.bsky.graph.follow/abc123'); 27 + final astronaut = ProfileView( 28 + did: 'did:plc:astro', 29 + handle: 'astro.bsky.social', 30 + displayName: 'Lina Orbit', 31 + description: 'Space systems engineer', 32 + createdAt: DateTime.utc(2024, 1, 1), 33 + ); 34 + final followingAstronaut = astronaut.copyWith(viewer: ViewerState(following: followingUri)); 35 + const gardener = ProfileView( 36 + did: 'did:plc:garden', 37 + handle: 'garden.bsky.social', 38 + displayName: 'Moss Vale', 39 + description: 'Native plant notes', 40 + ); 41 + 42 + setUp(() { 43 + profileRepository = MockProfileRepository(); 44 + profileActionRepository = MockProfileActionRepository(); 45 + connectivityCubit = MockConnectivityCubit(); 46 + 47 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 48 + whenListen( 49 + connectivityCubit, 50 + const Stream<ConnectivityState>.empty(), 51 + initialState: const ConnectivityState.online(), 52 + ); 53 + }); 54 + 55 + Widget buildSubject({ProfileConnectionsTab initialTab = ProfileConnectionsTab.following}) { 56 + return MultiRepositoryProvider( 57 + providers: [ 58 + RepositoryProvider<ProfileRepository>.value(value: profileRepository), 59 + RepositoryProvider<ProfileActionRepository>.value(value: profileActionRepository), 60 + RepositoryProvider<String>.value(value: 'did:plc:me'), 61 + ], 62 + child: BlocProvider( 63 + create: (_) => ProfileConnectionsCubit(repository: profileRepository, actor: subject.did), 64 + child: BlocProvider<ConnectivityCubit>.value( 65 + value: connectivityCubit, 66 + child: MaterialApp( 67 + home: ProfileConnectionsScreen(actor: subject.did, handle: subject.handle, initialTab: initialTab), 68 + ), 69 + ), 70 + ), 71 + ); 72 + } 73 + 74 + testWidgets('renders profiles with relationship action, description, and joined age', (tester) async { 75 + when( 76 + () => profileRepository.getFollowing(actor: subject.did, cursor: null, limit: 100), 77 + ).thenAnswer((_) async => ProfileConnectionsPage(subject: subject, profiles: [astronaut, followingAstronaut])); 78 + when( 79 + () => profileActionRepository.followActor(did: astronaut.did), 80 + ).thenAnswer((_) async => 'at://did:plc:me/app.bsky.graph.follow/new-follow'); 81 + 82 + await tester.pumpWidget(buildSubject()); 83 + await tester.pumpAndSettle(); 84 + 85 + expect(find.text('@alice.bsky.social'), findsOneWidget); 86 + expect(find.text('Mutuals'), findsOneWidget); 87 + expect(find.text('Lina Orbit'), findsWidgets); 88 + expect(find.text('Space systems engineer'), findsWidgets); 89 + expect(find.textContaining('Joined'), findsWidgets); 90 + expect(find.widgetWithText(FilledButton, 'Follow'), findsOneWidget); 91 + expect(find.widgetWithText(OutlinedButton, 'Following'), findsOneWidget); 92 + 93 + await tester.tap(find.widgetWithText(FilledButton, 'Follow')); 94 + await tester.pumpAndSettle(); 95 + 96 + verify(() => profileActionRepository.followActor(did: astronaut.did)).called(1); 97 + }); 98 + 99 + testWidgets('fuzzy search filters by profile description', (tester) async { 100 + var firstPageCalls = 0; 101 + when(() => profileRepository.getFollowing(actor: subject.did, cursor: null, limit: 100)).thenAnswer((_) async { 102 + firstPageCalls += 1; 103 + if (firstPageCalls == 1) { 104 + return const ProfileConnectionsPage(subject: subject, profiles: [gardener], cursor: 'next'); 105 + } 106 + return const ProfileConnectionsPage(subject: subject, profiles: [gardener], cursor: 'next'); 107 + }); 108 + when( 109 + () => profileRepository.getFollowing(actor: subject.did, cursor: 'next', limit: 100), 110 + ).thenAnswer((_) async => ProfileConnectionsPage(subject: subject, profiles: [astronaut])); 111 + 112 + await tester.pumpWidget(buildSubject()); 113 + await tester.pumpAndSettle(); 114 + 115 + await tester.enterText(find.byKey(const ValueKey('profile_connections_search_field')), 'space engineer'); 116 + await tester.pump(const Duration(milliseconds: 350)); 117 + await tester.pumpAndSettle(); 118 + 119 + expect(find.text('Lina Orbit'), findsOneWidget); 120 + expect(find.text('Moss Vale'), findsNothing); 121 + expect(find.text('Searched 2 accounts'), findsOneWidget); 122 + }); 123 + 124 + testWidgets('renders a retry footer when loading more fails', (tester) async { 125 + var loadMoreCalls = 0; 126 + when( 127 + () => profileRepository.getFollowing(actor: subject.did, cursor: null, limit: 100), 128 + ).thenAnswer((_) async => ProfileConnectionsPage(subject: subject, profiles: [astronaut], cursor: 'next')); 129 + when(() => profileRepository.getFollowing(actor: subject.did, cursor: 'next', limit: 100)).thenAnswer((_) async { 130 + loadMoreCalls += 1; 131 + if (loadMoreCalls == 1) { 132 + throw Exception('network down'); 133 + } 134 + return const ProfileConnectionsPage(subject: subject, profiles: [gardener]); 135 + }); 136 + 137 + await tester.pumpWidget(buildSubject()); 138 + await tester.pumpAndSettle(); 139 + 140 + await tester.tap(find.widgetWithText(OutlinedButton, 'Load more')); 141 + await tester.pumpAndSettle(); 142 + 143 + expect(find.textContaining('Failed to load more'), findsOneWidget); 144 + expect(find.widgetWithText(OutlinedButton, 'Retry'), findsOneWidget); 145 + 146 + await tester.tap(find.widgetWithText(OutlinedButton, 'Retry')); 147 + await tester.pumpAndSettle(); 148 + 149 + expect(find.text('Moss Vale'), findsOneWidget); 150 + expect(find.textContaining('Failed to load more'), findsNothing); 151 + }); 152 + 153 + testWidgets('loads the requested initial followers tab', (tester) async { 154 + when( 155 + () => profileRepository.getFollowers(actor: subject.did, cursor: null, limit: 100), 156 + ).thenAnswer((_) async => const ProfileConnectionsPage(subject: subject, profiles: [gardener])); 157 + 158 + await tester.pumpWidget(buildSubject(initialTab: ProfileConnectionsTab.followers)); 159 + await tester.pumpAndSettle(); 160 + 161 + verify(() => profileRepository.getFollowers(actor: subject.did, cursor: null, limit: 100)).called(1); 162 + expect(find.text('Moss Vale'), findsOneWidget); 163 + }); 164 + }
+36
test/features/profile/presentation/profile_screen_test.dart
··· 188 188 expect(find.text('Liked'), findsOneWidget); 189 189 }); 190 190 191 + testWidgets('tapping following stat opens connections screen on following tab', (tester) async { 192 + useLargeScreen(tester); 193 + final router = GoRouter( 194 + routes: [ 195 + GoRoute( 196 + path: '/', 197 + builder: (context, state) => MultiBlocProvider( 198 + providers: [ 199 + BlocProvider<AuthBloc>.value(value: authBloc), 200 + BlocProvider<ProfileBloc>.value(value: profileBloc), 201 + BlocProvider<FeedBloc>.value(value: feedBloc), 202 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 203 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 204 + ], 205 + child: const ProfileScreen(), 206 + ), 207 + ), 208 + GoRoute( 209 + path: '/profile/:actor/connections', 210 + builder: (context, state) => 211 + Scaffold(body: Text([state.uri.queryParameters['tab'], state.pathParameters['actor']].join(' '))), 212 + ), 213 + ], 214 + ); 215 + 216 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 217 + await tester.pumpAndSettle(); 218 + 219 + await tester.tap(find.byKey(const ValueKey('profile_following_stat'))); 220 + await tester.pumpAndSettle(); 221 + 222 + expect(find.text('following me.bsky.social'), findsOneWidget); 223 + 224 + router.dispose(); 225 + }); 226 + 191 227 testWidgets('does not show Bookmarks/Liked buttons on other profiles', (tester) async { 192 228 useLargeScreen(tester); 193 229 const otherProfile = ProfileViewDetailed(