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.

refactor/fix: sentinel pattern for state copyWith implementations

+383 -134
+20
docs/copywith.md
··· 33 33 - `SearchState.copyWith` (cursor and nullable metadata fields) 34 34 - `FeedState.copyWith` (cursor and error fields) 35 35 - `PostActionState.copyWith` (`likeUri`, `repostUri`, `error`) 36 + - `MessageState.copyWith` (`cursor`, `convoId`, `errorMessage`) 37 + - `ConvoListState.copyWith` (`cursor`, `errorMessage`) 38 + - `NotificationState.copyWith` (`cursor`, `errorMessage`) 39 + - `AuthState.copyWith` (`tokens`, `errorMessage`) 40 + - `AccountSwitcherState.copyWith` (`activeDid`) 41 + - `ProfileState.copyWith` (`profile`, `errorMessage`) 42 + - `AddToListState.copyWith` (`targetDid`, `errorMessage`) 43 + 44 + ## Already Using Sentinel Pattern 45 + 46 + - `ComposeState` / `VideoAttachment` / `MediaAttachment` 47 + - `ListState` 48 + - `ListFeedState` 49 + - `MyListsState` 50 + - `ActorStarterPacksState` 51 + - `StarterPackState` 52 + - `DevToolsState` 53 + - `LogViewerState` 54 + - `ProfileContextState` 55 + - `SettingsState` (for `threadAutoCollapseDepth`) 36 56 37 57 ## Review Checklist 38 58
+11 -7
lib/features/account/cubit/account_switcher_state.dart
··· 2 2 3 3 enum AccountSwitcherStatus { initial, loading, ready } 4 4 5 + const _accountSwitcherNoValue = Object(); 6 + 5 7 class AccountSwitcherState extends Equatable { 6 8 const AccountSwitcherState._({required this.status, this.accounts = const [], this.activeDid}); 7 9 ··· 18 20 19 21 Account? get activeAccount => accounts.where((a) => a.did == activeDid).firstOrNull; 20 22 21 - AccountSwitcherState copyWith({AccountSwitcherStatus? status, List<Account>? accounts, String? activeDid}) { 22 - return AccountSwitcherState._( 23 - status: status ?? this.status, 24 - accounts: accounts ?? this.accounts, 25 - activeDid: activeDid ?? this.activeDid, 26 - ); 27 - } 23 + AccountSwitcherState copyWith({ 24 + AccountSwitcherStatus? status, 25 + List<Account>? accounts, 26 + Object? activeDid = _accountSwitcherNoValue, 27 + }) => AccountSwitcherState._( 28 + status: status ?? this.status, 29 + accounts: accounts ?? this.accounts, 30 + activeDid: identical(activeDid, _accountSwitcherNoValue) ? this.activeDid : activeDid as String?, 31 + ); 28 32 29 33 @override 30 34 List<Object?> get props => [status, accounts, activeDid];
+11 -7
lib/features/auth/bloc/auth_state.dart
··· 2 2 3 3 enum AuthStatus { unauthenticated, authenticating, authenticated, authError } 4 4 5 + const _authStateNoValue = Object(); 6 + 5 7 class AuthState extends Equatable { 6 8 const AuthState._({required this.status, this.tokens, this.errorMessage}); 7 9 ··· 20 22 bool get isLoading => status == AuthStatus.authenticating; 21 23 bool get hasError => status == AuthStatus.authError; 22 24 23 - AuthState copyWith({AuthStatus? status, AuthTokens? tokens, String? errorMessage}) { 24 - return AuthState._( 25 - status: status ?? this.status, 26 - tokens: tokens ?? this.tokens, 27 - errorMessage: errorMessage ?? this.errorMessage, 28 - ); 29 - } 25 + AuthState copyWith({ 26 + AuthStatus? status, 27 + Object? tokens = _authStateNoValue, 28 + Object? errorMessage = _authStateNoValue, 29 + }) => AuthState._( 30 + status: status ?? this.status, 31 + tokens: identical(tokens, _authStateNoValue) ? this.tokens : tokens as AuthTokens?, 32 + errorMessage: identical(errorMessage, _authStateNoValue) ? this.errorMessage : errorMessage as String?, 33 + ); 30 34 31 35 @override 32 36 List<Object?> get props => [status, tokens, errorMessage];
+68 -20
lib/features/feed/cubit/feed_preferences_cubit.dart
··· 128 128 } 129 129 130 130 Future<void> addFeed({required SavedFeedType type, required String value, bool pinned = false}) async { 131 - if (state.feeds.any((feed) => feed.value == value)) { 131 + if (state.containsFeedValue(value)) { 132 132 log.d('FeedPreferencesCubit: Ignoring duplicate feed $value for $_accountDid'); 133 133 return; 134 134 } ··· 195 195 await _database.replaceSavedFeeds(_accountDid, companions); 196 196 } 197 197 198 - SavedFeed _mapFromCached(SavedFeedEntry entry) { 199 - return SavedFeed( 200 - id: entry.id, 201 - type: SavedFeedType.valueOf(entry.type) ?? const SavedFeedType.knownValue(data: KnownSavedFeedType.feed), 202 - value: entry.value, 203 - pinned: entry.pinned, 204 - ); 205 - } 198 + SavedFeed _mapFromCached(SavedFeedEntry entry) => SavedFeed( 199 + id: entry.id, 200 + type: SavedFeedType.valueOf(entry.type) ?? const SavedFeedType.knownValue(data: KnownSavedFeedType.feed), 201 + value: entry.value, 202 + pinned: entry.pinned, 203 + ); 206 204 207 205 String _generateId() => const Uuid().v4(); 208 206 ··· 218 216 return [_createDefaultTimelineFeed()]; 219 217 } 220 218 221 - List<GeneratorView> _retainGeneratorViews(List<SavedFeed> feeds) { 222 - final values = feeds.map((feed) => feed.value).toSet(); 223 - return state.generatorViews.where((view) => values.contains(view.uri.toString())).toList(growable: false); 224 - } 219 + List<GeneratorView> _retainGeneratorViews(List<SavedFeed> feeds) => state.generatorViews 220 + .where((view) => feeds.any((feed) => _isSameFeedValue(feed.value, view.uri.toString()))) 221 + .toList(growable: false); 225 222 226 223 Future<void> _hydrateGeneratorViews(List<SavedFeed> feeds) async { 227 224 final feedUris = <AtUri>[]; ··· 232 229 continue; 233 230 } 234 231 235 - if (!seenUris.add(feed.value)) { 236 - continue; 237 - } 238 - 239 232 try { 240 - feedUris.add(AtUri.parse(feed.value)); 233 + final parsed = AtUri.parse(feed.value); 234 + final key = '${parsed.hostname}/${parsed.collection}/${parsed.rkey}'; 235 + if (!seenUris.add(key)) { 236 + continue; 237 + } 238 + feedUris.add(parsed); 241 239 } catch (_) { 242 240 continue; 243 241 } ··· 260 258 error: e, 261 259 stackTrace: stackTrace, 262 260 ); 261 + 262 + // Fall back to per-feed hydration so one bad feed URI does not suppress 263 + // metadata (display names / avatars) for all remaining feeds. 264 + final fallbackViews = <GeneratorView>[]; 265 + for (final feedUri in feedUris) { 266 + try { 267 + fallbackViews.add(await _feedRepository.getFeedGenerator(feedUri)); 268 + } catch (_) { 269 + continue; 270 + } 271 + } 272 + 273 + if (fallbackViews.isNotEmpty) { 274 + log.d( 275 + 'FeedPreferencesCubit: Fallback hydrated ${fallbackViews.length}/${feedUris.length} generator views for $_accountDid', 276 + ); 277 + emit(state.copyWith(generatorViews: fallbackViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 278 + } 263 279 } 264 280 } 265 281 266 282 bool _isGeneratorFeed(SavedFeed feed) { 267 283 final feedType = feed.type; 268 284 return feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.feed; 285 + } 286 + 287 + bool _isSameFeedValue(String lhs, String rhs) { 288 + if (lhs == rhs) { 289 + return true; 290 + } 291 + 292 + try { 293 + final left = AtUri.parse(lhs); 294 + final right = AtUri.parse(rhs); 295 + return left.hostname == right.hostname && left.collection == right.collection && left.rkey == right.rkey; 296 + } catch (_) { 297 + return false; 298 + } 269 299 } 270 300 271 301 SavedFeed _createDefaultTimelineFeed() { ··· 325 355 return feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.timeline; 326 356 } 327 357 358 + bool containsFeedValue(String value) { 359 + return feeds.any((feed) => _isSameFeedValue(feed.value, value)); 360 + } 361 + 328 362 GeneratorView? generatorFor(SavedFeed feed) { 329 363 for (final view in generatorViews) { 330 - if (view.uri.toString() == feed.value) { 364 + if (_isSameFeedValue(view.uri.toString(), feed.value)) { 331 365 return view; 332 366 } 333 367 } ··· 341 375 } 342 376 343 377 final generator = generatorFor(feed); 344 - if (generator != null) { 378 + if (generator != null && generator.displayName.trim().isNotEmpty) { 345 379 return generator.displayName; 346 380 } 347 381 ··· 363 397 } 364 398 365 399 String? descriptionFor(SavedFeed feed) => generatorFor(feed)?.description; 400 + 401 + bool _isSameFeedValue(String lhs, String rhs) { 402 + if (lhs == rhs) { 403 + return true; 404 + } 405 + 406 + try { 407 + final left = AtUri.parse(lhs); 408 + final right = AtUri.parse(rhs); 409 + return left.hostname == right.hostname && left.collection == right.collection && left.rkey == right.rkey; 410 + } catch (_) { 411 + return false; 412 + } 413 + } 366 414 367 415 FeedPreferencesState copyWith({ 368 416 FeedPreferencesStatus? status,
+23 -7
lib/features/feed/presentation/feed_management_screen.dart
··· 116 116 itemBuilder: (context, index) { 117 117 final feed = pinnedFeeds[index]; 118 118 final isTimeline = state.isTimeline(feed); 119 + final generator = state.generatorFor(feed); 119 120 120 121 return ListTile( 121 122 key: ValueKey(feed.id), 122 - leading: isTimeline ? _buildTimelineIcon(context) : _buildFeedIcon(context, feed.value), 123 + leading: isTimeline 124 + ? _buildTimelineIcon(context) 125 + : (generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value)), 123 126 title: Text(state.displayNameFor(feed)), 124 127 subtitle: Text(state.subtitleFor(feed)), 125 128 trailing: ReorderableDragStartListener(index: index, child: const Icon(Icons.drag_handle)), ··· 154 157 155 158 Widget _buildPinnedFeedItem(BuildContext context, SavedFeed feed, FeedPreferencesState state) { 156 159 final isTimeline = state.isTimeline(feed); 160 + final generator = state.generatorFor(feed); 157 161 158 162 return ListTile( 159 - leading: isTimeline ? _buildTimelineIcon(context) : _buildFeedIcon(context, feed.value), 163 + leading: isTimeline 164 + ? _buildTimelineIcon(context) 165 + : (generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value)), 160 166 title: Text(state.displayNameFor(feed)), 161 167 subtitle: Text(state.subtitleFor(feed)), 162 168 trailing: IconButton( ··· 170 176 Widget _buildSavedFeedItem(BuildContext context, SavedFeed feed) { 171 177 final state = context.watch<FeedPreferencesCubit>().state; 172 178 final description = state.descriptionFor(feed); 179 + final generator = state.generatorFor(feed); 173 180 174 181 return ListTile( 175 - leading: _buildFeedIcon(context, feed.value), 182 + leading: generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value), 176 183 title: Text(state.displayNameFor(feed)), 177 184 subtitle: Text(description ?? state.subtitleFor(feed)), 178 185 trailing: Row( ··· 216 223 217 224 Widget _buildDiscoverCard(BuildContext context, GeneratorView feed) { 218 225 final prefsState = context.watch<FeedPreferencesCubit>().state; 219 - final isAdded = prefsState.feeds.any((f) => f.value == feed.uri.toString()); 226 + final isAdded = prefsState.containsFeedValue(feed.uri.toString()); 220 227 221 228 return Card( 222 229 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), ··· 231 238 crossAxisAlignment: CrossAxisAlignment.start, 232 239 children: [ 233 240 Text( 234 - feed.displayName, 241 + _feedDisplayName(feed), 235 242 style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), 236 243 ), 237 244 Text( ··· 311 318 } 312 319 313 320 Widget _buildGeneratorIcon(BuildContext context, GeneratorView feed) { 321 + final avatarUrl = feed.avatar ?? feed.creator.avatar; 314 322 return Container( 315 323 width: 40, 316 324 height: 40, ··· 318 326 gradient: const LinearGradient(colors: [Color(0xFF08BDBA), Color(0xFF3DDBD9)]), 319 327 borderRadius: BorderRadius.circular(10), 320 328 ), 321 - child: feed.avatar != null 329 + child: avatarUrl != null 322 330 ? ClipRRect( 323 331 borderRadius: BorderRadius.circular(10), 324 332 child: Image.network( 325 - feed.avatar!, 333 + avatarUrl, 326 334 fit: BoxFit.cover, 327 335 errorBuilder: (_, _, _) => const Icon(Icons.rss_feed, color: Colors.white), 328 336 ), 329 337 ) 330 338 : const Icon(Icons.rss_feed, color: Colors.white), 331 339 ); 340 + } 341 + 342 + String _feedDisplayName(GeneratorView feed) { 343 + final displayName = feed.displayName.trim(); 344 + if (displayName.isNotEmpty) { 345 + return displayName; 346 + } 347 + return feed.uri.rkey; 332 348 } 333 349 334 350 void _confirmRemoveFeed(BuildContext context, String feedId) {
+11 -11
lib/features/lists/cubit/add_to_list_state.dart
··· 2 2 3 3 enum AddToListStatus { initial, loading, loaded, error } 4 4 5 + const _addToListNoValue = Object(); 6 + 5 7 class AddToListState extends Equatable { 6 8 const AddToListState._({ 7 9 required this.status, ··· 30 32 31 33 AddToListState copyWith({ 32 34 AddToListStatus? status, 33 - String? targetDid, 35 + Object? targetDid = _addToListNoValue, 34 36 List<ListWithMembership>? lists, 35 37 Set<String>? togglingUris, 36 - String? errorMessage, 37 - }) { 38 - return AddToListState._( 39 - status: status ?? this.status, 40 - targetDid: targetDid ?? this.targetDid, 41 - lists: lists ?? this.lists, 42 - togglingUris: togglingUris ?? this.togglingUris, 43 - errorMessage: errorMessage ?? this.errorMessage, 44 - ); 45 - } 38 + Object? errorMessage = _addToListNoValue, 39 + }) => AddToListState._( 40 + status: status ?? this.status, 41 + targetDid: identical(targetDid, _addToListNoValue) ? this.targetDid : targetDid as String?, 42 + lists: lists ?? this.lists, 43 + togglingUris: togglingUris ?? this.togglingUris, 44 + errorMessage: identical(errorMessage, _addToListNoValue) ? this.errorMessage : errorMessage as String?, 45 + ); 46 46 47 47 @override 48 48 List<Object?> get props => [status, targetDid, lists, togglingUris, errorMessage];
+14 -14
lib/features/messages/bloc/convo_list_state.dart
··· 4 4 5 5 enum ConvoTab { primary, requests } 6 6 7 + const _convoListStateNoValue = Object(); 8 + 7 9 class ConvoListState extends Equatable { 8 10 const ConvoListState._({ 9 11 required this.status, ··· 41 43 ConvoListState copyWith({ 42 44 ConvoListStatus? status, 43 45 List<ConvoView>? convos, 44 - String? cursor, 46 + Object? cursor = _convoListStateNoValue, 45 47 bool? hasMore, 46 48 bool? isLoadingMore, 47 49 bool? isRefreshing, 48 50 ConvoTab? activeTab, 49 - String? errorMessage, 50 - }) { 51 - return ConvoListState._( 52 - status: status ?? this.status, 53 - convos: convos ?? this.convos, 54 - cursor: cursor ?? this.cursor, 55 - hasMore: hasMore ?? this.hasMore, 56 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, 57 - isRefreshing: isRefreshing ?? this.isRefreshing, 58 - activeTab: activeTab ?? this.activeTab, 59 - errorMessage: errorMessage ?? this.errorMessage, 60 - ); 61 - } 51 + Object? errorMessage = _convoListStateNoValue, 52 + }) => ConvoListState._( 53 + status: status ?? this.status, 54 + convos: convos ?? this.convos, 55 + cursor: identical(cursor, _convoListStateNoValue) ? this.cursor : cursor as String?, 56 + hasMore: hasMore ?? this.hasMore, 57 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 58 + isRefreshing: isRefreshing ?? this.isRefreshing, 59 + activeTab: activeTab ?? this.activeTab, 60 + errorMessage: identical(errorMessage, _convoListStateNoValue) ? this.errorMessage : errorMessage as String?, 61 + ); 62 62 63 63 @override 64 64 List<Object?> get props => [status, convos, cursor, hasMore, isLoadingMore, isRefreshing, activeTab, errorMessage];
+15 -15
lib/features/messages/bloc/message_state.dart
··· 2 2 3 3 enum MessageStatus { initial, loading, loaded, sending, error } 4 4 5 + const _messageStateNoValue = Object(); 6 + 5 7 class MessageState extends Equatable { 6 8 const MessageState._({ 7 9 required this.status, ··· 39 41 MessageState copyWith({ 40 42 MessageStatus? status, 41 43 List<UConvoGetMessagesMessages>? messages, 42 - String? cursor, 44 + Object? cursor = _messageStateNoValue, 43 45 bool? hasMore, 44 - String? convoId, 46 + Object? convoId = _messageStateNoValue, 45 47 bool? isSending, 46 48 bool? isLoadingMore, 47 - String? errorMessage, 48 - }) { 49 - return MessageState._( 50 - status: status ?? this.status, 51 - messages: messages ?? this.messages, 52 - cursor: cursor ?? this.cursor, 53 - hasMore: hasMore ?? this.hasMore, 54 - convoId: convoId ?? this.convoId, 55 - isSending: isSending ?? this.isSending, 56 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, 57 - errorMessage: errorMessage ?? this.errorMessage, 58 - ); 59 - } 49 + Object? errorMessage = _messageStateNoValue, 50 + }) => MessageState._( 51 + status: status ?? this.status, 52 + messages: messages ?? this.messages, 53 + cursor: identical(cursor, _messageStateNoValue) ? this.cursor : cursor as String?, 54 + hasMore: hasMore ?? this.hasMore, 55 + convoId: identical(convoId, _messageStateNoValue) ? this.convoId : convoId as String?, 56 + isSending: isSending ?? this.isSending, 57 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 58 + errorMessage: identical(errorMessage, _messageStateNoValue) ? this.errorMessage : errorMessage as String?, 59 + ); 60 60 61 61 @override 62 62 List<Object?> get props => [status, messages, cursor, hasMore, convoId, isSending, isLoadingMore, errorMessage];
+13 -13
lib/features/notifications/bloc/notification_state.dart
··· 2 2 3 3 enum NotificationStatus { initial, loading, loaded, error } 4 4 5 + const _notificationStateNoValue = Object(); 6 + 5 7 class NotificationState extends Equatable { 6 8 const NotificationState._({ 7 9 required this.status, ··· 33 35 NotificationState copyWith({ 34 36 NotificationStatus? status, 35 37 List<Notification>? notifications, 36 - String? cursor, 38 + Object? cursor = _notificationStateNoValue, 37 39 bool? hasMore, 38 40 bool? isLoadingMore, 39 41 bool? isRefreshing, 40 - String? errorMessage, 41 - }) { 42 - return NotificationState._( 43 - status: status ?? this.status, 44 - notifications: notifications ?? this.notifications, 45 - cursor: cursor ?? this.cursor, 46 - hasMore: hasMore ?? this.hasMore, 47 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, 48 - isRefreshing: isRefreshing ?? this.isRefreshing, 49 - errorMessage: errorMessage ?? this.errorMessage, 50 - ); 51 - } 42 + Object? errorMessage = _notificationStateNoValue, 43 + }) => NotificationState._( 44 + status: status ?? this.status, 45 + notifications: notifications ?? this.notifications, 46 + cursor: identical(cursor, _notificationStateNoValue) ? this.cursor : cursor as String?, 47 + hasMore: hasMore ?? this.hasMore, 48 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 49 + isRefreshing: isRefreshing ?? this.isRefreshing, 50 + errorMessage: identical(errorMessage, _notificationStateNoValue) ? this.errorMessage : errorMessage as String?, 51 + ); 52 52 53 53 @override 54 54 List<Object?> get props => [status, notifications, cursor, hasMore, isLoadingMore, isRefreshing, errorMessage];
+10 -10
lib/features/profile/bloc/profile_state.dart
··· 2 2 3 3 enum ProfileStatus { initial, loading, loaded, error } 4 4 5 + const _profileStateNoValue = Object(); 6 + 5 7 class ProfileState extends Equatable { 6 8 const ProfileState._({required this.status, this.profile, this.errorMessage, this.isRefreshing = false}); 7 9 ··· 25 27 26 28 ProfileState copyWith({ 27 29 ProfileStatus? status, 28 - ProfileViewDetailed? profile, 29 - String? errorMessage, 30 + Object? profile = _profileStateNoValue, 31 + Object? errorMessage = _profileStateNoValue, 30 32 bool? isRefreshing, 31 - }) { 32 - return ProfileState._( 33 - status: status ?? this.status, 34 - profile: profile ?? this.profile, 35 - errorMessage: errorMessage ?? this.errorMessage, 36 - isRefreshing: isRefreshing ?? this.isRefreshing, 37 - ); 38 - } 33 + }) => ProfileState._( 34 + status: status ?? this.status, 35 + profile: identical(profile, _profileStateNoValue) ? this.profile : profile as ProfileViewDetailed?, 36 + errorMessage: identical(errorMessage, _profileStateNoValue) ? this.errorMessage : errorMessage as String?, 37 + isRefreshing: isRefreshing ?? this.isRefreshing, 38 + ); 39 39 40 40 @override 41 41 List<Object?> get props => [status, profile, errorMessage, isRefreshing];
+41 -22
lib/features/search/presentation/search_screen.dart
··· 322 322 decoration: BoxDecoration( 323 323 border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 324 324 ), 325 - child: Row( 326 - children: [ 327 - _buildTab(context, SearchTab.posts, state), 328 - _buildTab(context, SearchTab.actors, state), 329 - _buildTab(context, SearchTab.feeds, state), 330 - _buildTab(context, SearchTab.starterPacks, state), 331 - ], 325 + child: SingleChildScrollView( 326 + scrollDirection: Axis.horizontal, 327 + child: Row( 328 + children: [ 329 + _buildTab(context, SearchTab.posts, state), 330 + _buildTab(context, SearchTab.actors, state), 331 + _buildTab(context, SearchTab.feeds, state), 332 + _buildTab(context, SearchTab.starterPacks, state), 333 + ], 334 + ), 332 335 ), 333 336 ); 334 337 } ··· 340 343 fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 341 344 color: isSelected ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, 342 345 ); 343 - return Expanded( 344 - child: InkWell( 345 - onTap: () => _onTabChanged(tab), 346 - child: Container( 347 - padding: const EdgeInsets.symmetric(vertical: 12), 348 - decoration: BoxDecoration( 349 - border: Border( 350 - bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 351 - ), 346 + return InkWell( 347 + onTap: () => _onTabChanged(tab), 348 + child: Container( 349 + constraints: const BoxConstraints(minWidth: 96), 350 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 351 + decoration: BoxDecoration( 352 + border: Border( 353 + bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 352 354 ), 353 - child: Text(tab.label, textAlign: TextAlign.center, style: textStyle), 355 + ), 356 + child: Text( 357 + tab.label, 358 + textAlign: TextAlign.center, 359 + style: textStyle, 360 + maxLines: 1, 361 + softWrap: false, 362 + overflow: TextOverflow.fade, 354 363 ), 355 364 ), 356 365 ); ··· 960 969 961 970 @override 962 971 Widget build(BuildContext context) { 972 + final displayName = _feedDisplayName(feed); 973 + final avatarUrl = feed.avatar ?? feed.creator.avatar; 963 974 final isAdded = context.select<FeedPreferencesCubit, bool>( 964 - (cubit) => cubit.state.feeds.any((savedFeed) => savedFeed.value == feed.uri.toString()), 975 + (cubit) => cubit.state.containsFeedValue(feed.uri.toString()), 965 976 ); 966 977 967 978 return Container( ··· 979 990 borderRadius: BorderRadius.circular(10), 980 991 gradient: const LinearGradient(colors: [Color(0xFF08BDBA), Color(0xFF3DDBD9)]), 981 992 ), 982 - child: feed.avatar != null 993 + child: avatarUrl != null 983 994 ? ClipRRect( 984 995 borderRadius: BorderRadius.circular(10), 985 996 child: Image.network( 986 - feed.avatar!, 997 + avatarUrl, 987 998 fit: BoxFit.cover, 988 999 errorBuilder: (_, _, _) => const Icon(Icons.rss_feed, color: Colors.white), 989 1000 ), ··· 996 1007 crossAxisAlignment: CrossAxisAlignment.start, 997 1008 children: [ 998 1009 Text( 999 - feed.displayName, 1010 + displayName, 1000 1011 style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), 1001 1012 maxLines: 1, 1002 1013 overflow: TextOverflow.ellipsis, ··· 1030 1041 pinned: false, 1031 1042 ); 1032 1043 if (!context.mounted) return; 1033 - onAdded(feed.displayName); 1044 + onAdded(displayName); 1034 1045 }, 1035 1046 child: Text(isAdded ? 'Added' : '+ Add'), 1036 1047 ), 1037 1048 ], 1038 1049 ), 1039 1050 ); 1051 + } 1052 + 1053 + String _feedDisplayName(GeneratorView value) { 1054 + final displayName = value.displayName.trim(); 1055 + if (displayName.isNotEmpty) { 1056 + return displayName; 1057 + } 1058 + return value.uri.rkey; 1040 1059 } 1041 1060 } 1042 1061
+3 -8
lib/main.dart
··· 182 182 previousRouter.dispose(); 183 183 } 184 184 185 - Bluesky? _createBluesky(AuthState state) { 186 - if (!state.isAuthenticated) return null; 187 - return createBlueskyClient(state.tokens); 188 - } 185 + Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; 189 186 190 - BlueskyChat? _createBlueskyChat(AuthState state) { 191 - if (!state.isAuthenticated) return null; 192 - return createBlueSkyChatClient(state.tokens); 193 - } 187 + BlueskyChat? _createBlueskyChat(AuthState state) => 188 + state.isAuthenticated ? createBlueSkyChatClient(state.tokens) : null; 194 189 195 190 @override 196 191 Widget build(BuildContext context) {
+65
test/features/feed/cubit/feed_preferences_cubit_test.dart
··· 16 16 late AppDatabase database; 17 17 late MockFeedRepository mockFeedRepository; 18 18 19 + setUpAll(() { 20 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.feed.generator/fallback')); 21 + }); 22 + 19 23 setUp(() async { 20 24 database = AppDatabase(executor: NativeDatabase.memory()); 21 25 mockFeedRepository = MockFeedRepository(); ··· 126 130 isA<FeedPreferencesState>() 127 131 .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) 128 132 .having((s) => s.feeds.length, 'feeds.length', 1), 133 + ], 134 + ); 135 + 136 + blocTest<FeedPreferencesCubit, FeedPreferencesState>( 137 + 'loadPreferences falls back to per-feed hydration when batch getFeedGenerators fails', 138 + build: () => 139 + FeedPreferencesCubit(feedRepository: mockFeedRepository, database: database, accountDid: 'did:plc:test'), 140 + setUp: () { 141 + final feed = createTestFeed(id: 'feed-1', pinned: true); 142 + final generatorView = GeneratorView( 143 + uri: AtUri.parse(feed.value), 144 + cid: 'cid-1', 145 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 146 + did: 'did:plc:test', 147 + displayName: 'Recovered Feed', 148 + indexedAt: DateTime.utc(2026, 3, 16), 149 + ); 150 + when(() => mockFeedRepository.getPreferences()).thenAnswer( 151 + (_) async => PreferencesResult( 152 + preferences: [ 153 + UPreferences.savedFeedsPrefV2(data: SavedFeedsPrefV2(items: [feed])), 154 + ], 155 + ), 156 + ); 157 + when(() => mockFeedRepository.getFeedGenerators(any())).thenThrow(Exception('batch failed')); 158 + when(() => mockFeedRepository.getFeedGenerator(any())).thenAnswer((_) async => generatorView); 159 + }, 160 + act: (cubit) => cubit.loadPreferences(), 161 + expect: () => [ 162 + isA<FeedPreferencesState>().having((s) => s.status, 'status', FeedPreferencesStatus.loading), 163 + isA<FeedPreferencesState>().having((s) => s.status, 'status', FeedPreferencesStatus.loaded), 164 + isA<FeedPreferencesState>() 165 + .having((s) => s.status, 'status', FeedPreferencesStatus.loaded) 166 + .having((s) => s.generatorViews.length, 'generatorViews.length', 1) 167 + .having((s) => s.displayNameFor(s.feeds.single), 'displayNameFor', 'Recovered Feed'), 129 168 ], 130 169 ); 131 170 ··· 371 410 ); 372 411 373 412 expect(state.displayNameFor(state.feeds.single), 'What\'s Hot'); 413 + }); 414 + 415 + test('displayNameFor falls back to URI rkey when hydrated displayName is empty', () { 416 + final feed = createTestFeed(id: '1'); 417 + final state = FeedPreferencesState.loaded( 418 + feeds: [feed], 419 + generatorViews: [ 420 + GeneratorView( 421 + uri: AtUri.parse(feed.value), 422 + cid: 'cid-1', 423 + creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), 424 + did: 'did:plc:test', 425 + displayName: ' ', 426 + indexedAt: DateTime.utc(2026, 3, 16), 427 + ), 428 + ], 429 + ); 430 + 431 + expect(state.displayNameFor(feed), 'test'); 432 + }); 433 + 434 + test('containsFeedValue matches exact saved feed value', () { 435 + final state = FeedPreferencesState.loaded(feeds: [createTestFeed(id: '1')]); 436 + 437 + expect(state.containsFeedValue('at://did:plc:test/app.bsky.feed.generator/test'), isTrue); 438 + expect(state.containsFeedValue('at://did:plc:test/app.bsky.feed.generator/other'), isFalse); 374 439 }); 375 440 }); 376 441 }
+20
test/features/lists/cubit/add_to_list_cubit_test.dart
··· 141 141 ], 142 142 ); 143 143 }); 144 + 145 + group('AddToListState', () { 146 + test('copyWith can clear nullable fields', () { 147 + const state = AddToListState.error(message: 'boom', targetDid: 'did:plc:target'); 148 + 149 + final cleared = state.copyWith(targetDid: null, errorMessage: null); 150 + 151 + expect(cleared.targetDid, isNull); 152 + expect(cleared.errorMessage, isNull); 153 + }); 154 + 155 + test('copyWith preserves nullable fields when omitted', () { 156 + const state = AddToListState.error(message: 'boom', targetDid: 'did:plc:target'); 157 + 158 + final copied = state.copyWith(status: AddToListStatus.loading); 159 + 160 + expect(copied.targetDid, 'did:plc:target'); 161 + expect(copied.errorMessage, 'boom'); 162 + }); 163 + }); 144 164 }
+58
test/features/search/presentation/search_screen_test.dart
··· 161 161 expect(find.text('Latest'), findsNothing); 162 162 }); 163 163 164 + testWidgets('tabs are horizontally scrollable and starter packs label stays single-line', (tester) async { 165 + await tester.pumpWidget(buildSubject()); 166 + await tester.pumpAndSettle(); 167 + 168 + expect( 169 + find.byWidgetPredicate((w) => w is SingleChildScrollView && w.scrollDirection == Axis.horizontal), 170 + findsOneWidget, 171 + ); 172 + 173 + final starterPackLabel = tester.widget<Text>(find.text('Starter Packs')); 174 + expect(starterPackLabel.maxLines, 1); 175 + expect(starterPackLabel.softWrap, isFalse); 176 + }); 177 + 164 178 testWidgets('shows empty state when no search history', (tester) async { 165 179 await tester.pumpWidget(buildSubject()); 166 180 await tester.pumpAndSettle(); ··· 476 490 await tester.tap(find.text('Manage')); 477 491 await tester.pumpAndSettle(); 478 492 expect(find.text('feeds-page'), findsOneWidget); 493 + }); 494 + 495 + testWidgets('feed result falls back to URI rkey when displayName is empty', (tester) async { 496 + final sampleFeed = GeneratorView( 497 + uri: AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/fallback-name'), 498 + cid: 'cid-feed', 499 + did: 'did:web:feed.example.com', 500 + creator: ProfileView( 501 + did: 'did:plc:creator', 502 + handle: 'creator.bsky.social', 503 + avatar: 'https://example.com/creator-avatar.jpg', 504 + indexedAt: DateTime.utc(2026, 1, 1), 505 + ), 506 + displayName: ' ', 507 + indexedAt: DateTime.utc(2026, 1, 1), 508 + ); 509 + 510 + when( 511 + () => mockSearchRepository.searchFeedGenerators( 512 + query: any(named: 'query'), 513 + cursor: any(named: 'cursor'), 514 + limit: any(named: 'limit'), 515 + ), 516 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [sampleFeed])); 517 + 518 + when( 519 + () => mockDatabase.addSearchHistoryEntry( 520 + query: any(named: 'query'), 521 + type: any(named: 'type'), 522 + accountDid: any(named: 'accountDid'), 523 + ), 524 + ).thenAnswer((_) async {}); 525 + 526 + await tester.pumpWidget(buildSubject()); 527 + await tester.pumpAndSettle(); 528 + 529 + await tester.tap(find.text('Feeds')); 530 + await tester.pumpAndSettle(); 531 + 532 + await tester.enterText(find.byType(TextField).first, 'fallback'); 533 + await tester.testTextInput.receiveAction(TextInputAction.search); 534 + await tester.pumpAndSettle(); 535 + 536 + expect(find.text('fallback-name'), findsOneWidget); 479 537 }); 480 538 481 539 testWidgets('starter pack results display name and creator handle', (tester) async {