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: nullable copyWith pattern for state management

+121 -63
+40
docs/copywith.md
··· 1 + # Nullable `copyWith` Rule 2 + 3 + ## Problem 4 + 5 + A common state bug occurs when `copyWith` uses `field ?? this.field` for nullable fields. 6 + That pattern cannot distinguish: 7 + 8 + - "keep current value" 9 + - "set this field to null" 10 + 11 + This breaks flows where `null` is meaningful (for example clearing cursors, `likeUri`, `repostUri`, or `errorMessage`). 12 + 13 + ## Required Pattern 14 + 15 + For nullable fields in immutable state objects, use a sentinel parameter default: 16 + 17 + ```dart 18 + static const Object _unset = Object(); 19 + 20 + State copyWith({ 21 + Object? nullableField = _unset, 22 + }) { 23 + return State( 24 + nullableField: identical(nullableField, _unset) 25 + ? this.nullableField 26 + : nullableField as String?, 27 + ); 28 + } 29 + ``` 30 + 31 + ## Where Applied 32 + 33 + - `SearchState.copyWith` (cursor and nullable metadata fields) 34 + - `FeedState.copyWith` (cursor and error fields) 35 + - `PostActionState.copyWith` (`likeUri`, `repostUri`, `error`) 36 + 37 + ## Review Checklist 38 + 39 + - If a field is nullable and needs to be clearable, do not use `??` in `copyWith`. 40 + - Add/keep tests that assert nullable fields can be explicitly cleared to `null`.
+15 -15
lib/features/feed/bloc/feed_state.dart
··· 30 30 31 31 const FeedState.error(String message) : this._(status: FeedStatus.error, errorMessage: message); 32 32 33 + static const Object _unset = Object(); 34 + 33 35 final FeedStatus status; 34 36 final String? actor; 35 37 final List<FeedViewPost> posts; ··· 48 50 FeedStatus? status, 49 51 String? actor, 50 52 List<FeedViewPost>? posts, 51 - String? cursor, 52 - String? errorMessage, 53 + Object? cursor = _unset, 54 + Object? errorMessage = _unset, 53 55 FeedFilter? filter, 54 56 bool? hasMore, 55 57 bool? isLoadingMore, 56 58 bool? isRefreshing, 57 - }) { 58 - return FeedState._( 59 - status: status ?? this.status, 60 - actor: actor ?? this.actor, 61 - posts: posts ?? this.posts, 62 - cursor: cursor ?? this.cursor, 63 - errorMessage: errorMessage ?? this.errorMessage, 64 - filter: filter ?? this.filter, 65 - hasMore: hasMore ?? this.hasMore, 66 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, 67 - isRefreshing: isRefreshing ?? this.isRefreshing, 68 - ); 69 - } 59 + }) => FeedState._( 60 + status: status ?? this.status, 61 + actor: actor ?? this.actor, 62 + posts: posts ?? this.posts, 63 + cursor: identical(cursor, _unset) ? this.cursor : cursor as String?, 64 + errorMessage: identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?, 65 + filter: filter ?? this.filter, 66 + hasMore: hasMore ?? this.hasMore, 67 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 68 + isRefreshing: isRefreshing ?? this.isRefreshing, 69 + ); 70 70 71 71 @override 72 72 List<Object?> get props => [status, actor, posts, cursor, errorMessage, filter, hasMore, isLoadingMore, isRefreshing];
+19 -18
lib/features/feed/cubit/post_action_cubit.dart
··· 19 19 this.isDeleted = false, 20 20 this.error, 21 21 }); 22 + // Sentinel used by copyWith to distinguish "keep existing value" 23 + // from an explicit null assignment for nullable fields. 24 + static const Object _unset = Object(); 22 25 23 26 final String postUri; 24 27 final bool isLiked; ··· 37 40 bool? isReposted, 38 41 int? likeCount, 39 42 int? repostCount, 40 - String? likeUri, 41 - String? repostUri, 43 + Object? likeUri = _unset, 44 + Object? repostUri = _unset, 42 45 bool? isLoadingLike, 43 46 bool? isLoadingRepost, 44 47 bool? isDeleted, 45 - String? error, 46 - }) { 47 - return PostActionState( 48 - postUri: postUri, 49 - isLiked: isLiked ?? this.isLiked, 50 - isReposted: isReposted ?? this.isReposted, 51 - likeCount: likeCount ?? this.likeCount, 52 - repostCount: repostCount ?? this.repostCount, 53 - likeUri: likeUri ?? this.likeUri, 54 - repostUri: repostUri ?? this.repostUri, 55 - isLoadingLike: isLoadingLike ?? this.isLoadingLike, 56 - isLoadingRepost: isLoadingRepost ?? this.isLoadingRepost, 57 - isDeleted: isDeleted ?? this.isDeleted, 58 - error: error ?? this.error, 59 - ); 60 - } 48 + Object? error = _unset, 49 + }) => PostActionState( 50 + postUri: postUri, 51 + isLiked: isLiked ?? this.isLiked, 52 + isReposted: isReposted ?? this.isReposted, 53 + likeCount: likeCount ?? this.likeCount, 54 + repostCount: repostCount ?? this.repostCount, 55 + likeUri: identical(likeUri, _unset) ? this.likeUri : likeUri as String?, 56 + repostUri: identical(repostUri, _unset) ? this.repostUri : repostUri as String?, 57 + isLoadingLike: isLoadingLike ?? this.isLoadingLike, 58 + isLoadingRepost: isLoadingRepost ?? this.isLoadingRepost, 59 + isDeleted: isDeleted ?? this.isDeleted, 60 + error: identical(error, _unset) ? this.error : error as String?, 61 + ); 61 62 62 63 @override 63 64 List<Object?> get props => [
+3 -1
lib/features/feed/presentation/post_thread_screen.dart
··· 10 10 import 'package:go_router/go_router.dart'; 11 11 import 'package:intl/intl.dart'; 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 13 14 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 14 - import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 15 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 15 16 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 16 17 import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; 17 18 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; ··· 629 630 repostCount: post.repostCount ?? 0, 630 631 likeUri: viewer?.like?.toString(), 631 632 repostUri: viewer?.repost?.toString(), 633 + cache: context.read<PostActionCache>(), 632 634 ), 633 635 child: BlocListener<PostActionCubit, PostActionState>( 634 636 listenWhen: (previous, current) => previous.error != current.error && current.error != null,
+27 -29
lib/features/search/bloc/search_state.dart
··· 15 15 top, 16 16 latest; 17 17 18 - factory SearchSort.fromString(String value) { 19 - return switch (value) { 20 - 'top' => SearchSort.top, 21 - _ => SearchSort.latest, 22 - }; 23 - } 18 + factory SearchSort.fromString(String value) => switch (value) { 19 + 'top' => SearchSort.top, 20 + _ => SearchSort.latest, 21 + }; 24 22 } 25 23 26 24 extension SearchSortLabel on SearchSort { ··· 101 99 const SearchState.error({required String query, required String message}) 102 100 : this._(status: SearchStatus.error, query: query, errorMessage: message); 103 101 102 + static const Object _unset = Object(); 103 + 104 104 final SearchStatus status; 105 105 final String query; 106 106 final SearchTab currentTab; ··· 131 131 List<ProfileView>? actors, 132 132 List<GeneratorView>? feeds, 133 133 List<StarterPackViewBasic>? starterPacks, 134 - String? cursor, 135 - String? starterPacksCursor, 136 - int? hitsTotal, 137 - String? errorMessage, 134 + Object? cursor = _unset, 135 + Object? starterPacksCursor = _unset, 136 + Object? hitsTotal = _unset, 137 + Object? errorMessage = _unset, 138 138 bool? isLoadingMore, 139 139 List<ProfileViewBasic>? typeaheadActors, 140 140 List<SearchHistoryEntry>? searchHistory, 141 - }) { 142 - return SearchState._( 143 - status: status ?? this.status, 144 - query: query ?? this.query, 145 - currentTab: currentTab ?? this.currentTab, 146 - currentSort: currentSort ?? this.currentSort, 147 - posts: posts ?? this.posts, 148 - actors: actors ?? this.actors, 149 - feeds: feeds ?? this.feeds, 150 - starterPacks: starterPacks ?? this.starterPacks, 151 - cursor: cursor ?? this.cursor, 152 - starterPacksCursor: starterPacksCursor ?? this.starterPacksCursor, 153 - hitsTotal: hitsTotal ?? this.hitsTotal, 154 - errorMessage: errorMessage ?? this.errorMessage, 155 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, 156 - typeaheadActors: typeaheadActors ?? this.typeaheadActors, 157 - searchHistory: searchHistory ?? this.searchHistory, 158 - ); 159 - } 141 + }) => SearchState._( 142 + status: status ?? this.status, 143 + query: query ?? this.query, 144 + currentTab: currentTab ?? this.currentTab, 145 + currentSort: currentSort ?? this.currentSort, 146 + posts: posts ?? this.posts, 147 + actors: actors ?? this.actors, 148 + feeds: feeds ?? this.feeds, 149 + starterPacks: starterPacks ?? this.starterPacks, 150 + cursor: identical(cursor, _unset) ? this.cursor : cursor as String?, 151 + starterPacksCursor: identical(starterPacksCursor, _unset) ? this.starterPacksCursor : starterPacksCursor as String?, 152 + hitsTotal: identical(hitsTotal, _unset) ? this.hitsTotal : hitsTotal as int?, 153 + errorMessage: identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?, 154 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 155 + typeaheadActors: typeaheadActors ?? this.typeaheadActors, 156 + searchHistory: searchHistory ?? this.searchHistory, 157 + ); 160 158 161 159 @override 162 160 List<Object?> get props => [
+17
test/features/feed/cubit/post_action_cubit_test.dart
··· 112 112 isA<PostActionState>() 113 113 .having((s) => s.isLiked, 'isLiked', isFalse) 114 114 .having((s) => s.likeCount, 'likeCount', 4) 115 + .having((s) => s.likeUri, 'likeUri', isNull) 115 116 .having((s) => s.isLoadingLike, 'isLoadingLike', isFalse), 116 117 ], 117 118 ); ··· 208 209 isA<PostActionState>() 209 210 .having((s) => s.isReposted, 'isReposted', isFalse) 210 211 .having((s) => s.repostCount, 'repostCount', 2) 212 + .having((s) => s.repostUri, 'repostUri', isNull) 211 213 .having((s) => s.isLoadingRepost, 'isLoadingRepost', isFalse), 212 214 ], 213 215 ); ··· 466 468 expect(copied.isLiked, isTrue); 467 469 expect(copied.likeCount, 1); 468 470 expect(copied.postUri, testPostUri); 471 + }); 472 + 473 + test('copyWith can explicitly clear nullable fields', () { 474 + const state = PostActionState( 475 + postUri: testPostUri, 476 + likeUri: testLikeUri, 477 + repostUri: testRepostUri, 478 + error: 'oops', 479 + ); 480 + 481 + final copied = state.copyWith(likeUri: null, repostUri: null, error: null); 482 + 483 + expect(copied.likeUri, isNull); 484 + expect(copied.repostUri, isNull); 485 + expect(copied.error, isNull); 469 486 }); 470 487 }); 471 488 }