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: save type for posts

* add options to save UI

+315 -30
+2 -2
docs/BUGS.md
··· 13 13 - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 14 14 - [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 15 - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 - - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 - - [ ] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 16 + - [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 + - [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 18 18 - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) 19 19 - [ ] [12. Delete Post — Remove from Feed](#12-delete-post--remove-from-feed) 20 20
+10 -1
lib/core/database/app_database.dart
··· 11 11 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 12 12 13 13 @override 14 - int get schemaVersion => 6; 14 + int get schemaVersion => 7; 15 15 16 16 @override 17 17 MigrationStrategy get migration => MigrationStrategy( ··· 37 37 } 38 38 if (from < 6) { 39 39 await migrator.createTable(savedPosts); 40 + } 41 + if (from < 7) { 42 + await migrator.addColumn(savedPosts, savedPosts.saveType); 40 43 } 41 44 }, 42 45 ); ··· 269 272 return (select( 270 273 savedPosts, 271 274 )..where((s) => s.accountDid.equals(accountDid))).watch().map((posts) => posts.map((p) => p.postUri).toSet()); 275 + } 276 + 277 + Stream<Map<String, String>> watchSavedPostsWithType(String accountDid) { 278 + return (select(savedPosts)..where((s) => s.accountDid.equals(accountDid))).watch().map( 279 + (posts) => {for (final p in posts) p.postUri: p.saveType}, 280 + ); 272 281 } 273 282 }
+65 -10
lib/core/database/app_database.g.dart
··· 2709 2709 type: DriftSqlType.string, 2710 2710 requiredDuringInsert: true, 2711 2711 ); 2712 + static const VerificationMeta _saveTypeMeta = const VerificationMeta('saveType'); 2713 + @override 2714 + late final GeneratedColumn<String> saveType = GeneratedColumn<String>( 2715 + 'save_type', 2716 + aliasedName, 2717 + false, 2718 + type: DriftSqlType.string, 2719 + requiredDuringInsert: false, 2720 + defaultValue: const Constant('local'), 2721 + ); 2712 2722 static const VerificationMeta _savedAtMeta = const VerificationMeta('savedAt'); 2713 2723 @override 2714 2724 late final GeneratedColumn<DateTime> savedAt = GeneratedColumn<DateTime>( ··· 2720 2730 defaultValue: currentDateAndTime, 2721 2731 ); 2722 2732 @override 2723 - List<GeneratedColumn> get $columns => [id, accountDid, postUri, postJson, savedAt]; 2733 + List<GeneratedColumn> get $columns => [id, accountDid, postUri, postJson, saveType, savedAt]; 2724 2734 @override 2725 2735 String get aliasedName => _alias ?? actualTableName; 2726 2736 @override ··· 2748 2758 } else if (isInserting) { 2749 2759 context.missing(_postJsonMeta); 2750 2760 } 2761 + if (data.containsKey('save_type')) { 2762 + context.handle(_saveTypeMeta, saveType.isAcceptableOrUnknown(data['save_type']!, _saveTypeMeta)); 2763 + } 2751 2764 if (data.containsKey('saved_at')) { 2752 2765 context.handle(_savedAtMeta, savedAt.isAcceptableOrUnknown(data['saved_at']!, _savedAtMeta)); 2753 2766 } ··· 2764 2777 accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, 2765 2778 postUri: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}post_uri'])!, 2766 2779 postJson: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}post_json'])!, 2780 + saveType: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}save_type'])!, 2767 2781 savedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}saved_at'])!, 2768 2782 ); 2769 2783 } ··· 2779 2793 final String accountDid; 2780 2794 final String postUri; 2781 2795 final String postJson; 2796 + final String saveType; 2782 2797 final DateTime savedAt; 2783 2798 const SavedPostEntry({ 2784 2799 required this.id, 2785 2800 required this.accountDid, 2786 2801 required this.postUri, 2787 2802 required this.postJson, 2803 + required this.saveType, 2788 2804 required this.savedAt, 2789 2805 }); 2790 2806 @override ··· 2794 2810 map['account_did'] = Variable<String>(accountDid); 2795 2811 map['post_uri'] = Variable<String>(postUri); 2796 2812 map['post_json'] = Variable<String>(postJson); 2813 + map['save_type'] = Variable<String>(saveType); 2797 2814 map['saved_at'] = Variable<DateTime>(savedAt); 2798 2815 return map; 2799 2816 } ··· 2804 2821 accountDid: Value(accountDid), 2805 2822 postUri: Value(postUri), 2806 2823 postJson: Value(postJson), 2824 + saveType: Value(saveType), 2807 2825 savedAt: Value(savedAt), 2808 2826 ); 2809 2827 } ··· 2815 2833 accountDid: serializer.fromJson<String>(json['accountDid']), 2816 2834 postUri: serializer.fromJson<String>(json['postUri']), 2817 2835 postJson: serializer.fromJson<String>(json['postJson']), 2836 + saveType: serializer.fromJson<String>(json['saveType']), 2818 2837 savedAt: serializer.fromJson<DateTime>(json['savedAt']), 2819 2838 ); 2820 2839 } ··· 2826 2845 'accountDid': serializer.toJson<String>(accountDid), 2827 2846 'postUri': serializer.toJson<String>(postUri), 2828 2847 'postJson': serializer.toJson<String>(postJson), 2848 + 'saveType': serializer.toJson<String>(saveType), 2829 2849 'savedAt': serializer.toJson<DateTime>(savedAt), 2830 2850 }; 2831 2851 } 2832 2852 2833 - SavedPostEntry copyWith({int? id, String? accountDid, String? postUri, String? postJson, DateTime? savedAt}) => 2834 - SavedPostEntry( 2835 - id: id ?? this.id, 2836 - accountDid: accountDid ?? this.accountDid, 2837 - postUri: postUri ?? this.postUri, 2838 - postJson: postJson ?? this.postJson, 2839 - savedAt: savedAt ?? this.savedAt, 2840 - ); 2853 + SavedPostEntry copyWith({ 2854 + int? id, 2855 + String? accountDid, 2856 + String? postUri, 2857 + String? postJson, 2858 + String? saveType, 2859 + DateTime? savedAt, 2860 + }) => SavedPostEntry( 2861 + id: id ?? this.id, 2862 + accountDid: accountDid ?? this.accountDid, 2863 + postUri: postUri ?? this.postUri, 2864 + postJson: postJson ?? this.postJson, 2865 + saveType: saveType ?? this.saveType, 2866 + savedAt: savedAt ?? this.savedAt, 2867 + ); 2841 2868 SavedPostEntry copyWithCompanion(SavedPostsCompanion data) { 2842 2869 return SavedPostEntry( 2843 2870 id: data.id.present ? data.id.value : this.id, 2844 2871 accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, 2845 2872 postUri: data.postUri.present ? data.postUri.value : this.postUri, 2846 2873 postJson: data.postJson.present ? data.postJson.value : this.postJson, 2874 + saveType: data.saveType.present ? data.saveType.value : this.saveType, 2847 2875 savedAt: data.savedAt.present ? data.savedAt.value : this.savedAt, 2848 2876 ); 2849 2877 } ··· 2855 2883 ..write('accountDid: $accountDid, ') 2856 2884 ..write('postUri: $postUri, ') 2857 2885 ..write('postJson: $postJson, ') 2886 + ..write('saveType: $saveType, ') 2858 2887 ..write('savedAt: $savedAt') 2859 2888 ..write(')')) 2860 2889 .toString(); 2861 2890 } 2862 2891 2863 2892 @override 2864 - int get hashCode => Object.hash(id, accountDid, postUri, postJson, savedAt); 2893 + int get hashCode => Object.hash(id, accountDid, postUri, postJson, saveType, savedAt); 2865 2894 @override 2866 2895 bool operator ==(Object other) => 2867 2896 identical(this, other) || ··· 2870 2899 other.accountDid == this.accountDid && 2871 2900 other.postUri == this.postUri && 2872 2901 other.postJson == this.postJson && 2902 + other.saveType == this.saveType && 2873 2903 other.savedAt == this.savedAt); 2874 2904 } 2875 2905 ··· 2878 2908 final Value<String> accountDid; 2879 2909 final Value<String> postUri; 2880 2910 final Value<String> postJson; 2911 + final Value<String> saveType; 2881 2912 final Value<DateTime> savedAt; 2882 2913 const SavedPostsCompanion({ 2883 2914 this.id = const Value.absent(), 2884 2915 this.accountDid = const Value.absent(), 2885 2916 this.postUri = const Value.absent(), 2886 2917 this.postJson = const Value.absent(), 2918 + this.saveType = const Value.absent(), 2887 2919 this.savedAt = const Value.absent(), 2888 2920 }); 2889 2921 SavedPostsCompanion.insert({ ··· 2891 2923 required String accountDid, 2892 2924 required String postUri, 2893 2925 required String postJson, 2926 + this.saveType = const Value.absent(), 2894 2927 this.savedAt = const Value.absent(), 2895 2928 }) : accountDid = Value(accountDid), 2896 2929 postUri = Value(postUri), ··· 2900 2933 Expression<String>? accountDid, 2901 2934 Expression<String>? postUri, 2902 2935 Expression<String>? postJson, 2936 + Expression<String>? saveType, 2903 2937 Expression<DateTime>? savedAt, 2904 2938 }) { 2905 2939 return RawValuesInsertable({ ··· 2907 2941 if (accountDid != null) 'account_did': accountDid, 2908 2942 if (postUri != null) 'post_uri': postUri, 2909 2943 if (postJson != null) 'post_json': postJson, 2944 + if (saveType != null) 'save_type': saveType, 2910 2945 if (savedAt != null) 'saved_at': savedAt, 2911 2946 }); 2912 2947 } ··· 2916 2951 Value<String>? accountDid, 2917 2952 Value<String>? postUri, 2918 2953 Value<String>? postJson, 2954 + Value<String>? saveType, 2919 2955 Value<DateTime>? savedAt, 2920 2956 }) { 2921 2957 return SavedPostsCompanion( ··· 2923 2959 accountDid: accountDid ?? this.accountDid, 2924 2960 postUri: postUri ?? this.postUri, 2925 2961 postJson: postJson ?? this.postJson, 2962 + saveType: saveType ?? this.saveType, 2926 2963 savedAt: savedAt ?? this.savedAt, 2927 2964 ); 2928 2965 } ··· 2942 2979 if (postJson.present) { 2943 2980 map['post_json'] = Variable<String>(postJson.value); 2944 2981 } 2982 + if (saveType.present) { 2983 + map['save_type'] = Variable<String>(saveType.value); 2984 + } 2945 2985 if (savedAt.present) { 2946 2986 map['saved_at'] = Variable<DateTime>(savedAt.value); 2947 2987 } ··· 2955 2995 ..write('accountDid: $accountDid, ') 2956 2996 ..write('postUri: $postUri, ') 2957 2997 ..write('postJson: $postJson, ') 2998 + ..write('saveType: $saveType, ') 2958 2999 ..write('savedAt: $savedAt') 2959 3000 ..write(')')) 2960 3001 .toString(); ··· 4270 4311 required String accountDid, 4271 4312 required String postUri, 4272 4313 required String postJson, 4314 + Value<String> saveType, 4273 4315 Value<DateTime> savedAt, 4274 4316 }); 4275 4317 typedef $$SavedPostsTableUpdateCompanionBuilder = ··· 4278 4320 Value<String> accountDid, 4279 4321 Value<String> postUri, 4280 4322 Value<String> postJson, 4323 + Value<String> saveType, 4281 4324 Value<DateTime> savedAt, 4282 4325 }); 4283 4326 ··· 4300 4343 ColumnFilters<String> get postJson => 4301 4344 $composableBuilder(column: $table.postJson, builder: (column) => ColumnFilters(column)); 4302 4345 4346 + ColumnFilters<String> get saveType => 4347 + $composableBuilder(column: $table.saveType, builder: (column) => ColumnFilters(column)); 4348 + 4303 4349 ColumnFilters<DateTime> get savedAt => 4304 4350 $composableBuilder(column: $table.savedAt, builder: (column) => ColumnFilters(column)); 4305 4351 } ··· 4323 4369 ColumnOrderings<String> get postJson => 4324 4370 $composableBuilder(column: $table.postJson, builder: (column) => ColumnOrderings(column)); 4325 4371 4372 + ColumnOrderings<String> get saveType => 4373 + $composableBuilder(column: $table.saveType, builder: (column) => ColumnOrderings(column)); 4374 + 4326 4375 ColumnOrderings<DateTime> get savedAt => 4327 4376 $composableBuilder(column: $table.savedAt, builder: (column) => ColumnOrderings(column)); 4328 4377 } ··· 4342 4391 GeneratedColumn<String> get postUri => $composableBuilder(column: $table.postUri, builder: (column) => column); 4343 4392 4344 4393 GeneratedColumn<String> get postJson => $composableBuilder(column: $table.postJson, builder: (column) => column); 4394 + 4395 + GeneratedColumn<String> get saveType => $composableBuilder(column: $table.saveType, builder: (column) => column); 4345 4396 4346 4397 GeneratedColumn<DateTime> get savedAt => $composableBuilder(column: $table.savedAt, builder: (column) => column); 4347 4398 } ··· 4375 4426 Value<String> accountDid = const Value.absent(), 4376 4427 Value<String> postUri = const Value.absent(), 4377 4428 Value<String> postJson = const Value.absent(), 4429 + Value<String> saveType = const Value.absent(), 4378 4430 Value<DateTime> savedAt = const Value.absent(), 4379 4431 }) => SavedPostsCompanion( 4380 4432 id: id, 4381 4433 accountDid: accountDid, 4382 4434 postUri: postUri, 4383 4435 postJson: postJson, 4436 + saveType: saveType, 4384 4437 savedAt: savedAt, 4385 4438 ), 4386 4439 createCompanionCallback: ··· 4389 4442 required String accountDid, 4390 4443 required String postUri, 4391 4444 required String postJson, 4445 + Value<String> saveType = const Value.absent(), 4392 4446 Value<DateTime> savedAt = const Value.absent(), 4393 4447 }) => SavedPostsCompanion.insert( 4394 4448 id: id, 4395 4449 accountDid: accountDid, 4396 4450 postUri: postUri, 4397 4451 postJson: postJson, 4452 + saveType: saveType, 4398 4453 savedAt: savedAt, 4399 4454 ), 4400 4455 withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(),
+1
lib/core/database/tables.dart
··· 97 97 TextColumn get accountDid => text()(); 98 98 TextColumn get postUri => text()(); 99 99 TextColumn get postJson => text()(); 100 + TextColumn get saveType => text().withDefault(const Constant('local'))(); 100 101 DateTimeColumn get savedAt => dateTime().withDefault(currentDateAndTime)(); 101 102 102 103 @override
+15 -5
lib/features/feed/cubit/saved_posts_cubit.dart
··· 11 11 this.status = SavedPostsStatus.initial, 12 12 this.savedPosts = const [], 13 13 this.savedUris = const {}, 14 + this.saveTypeByUri = const {}, 14 15 this.error, 15 16 }); 16 17 17 18 final SavedPostsStatus status; 18 19 final List<SavedPostEntry> savedPosts; 19 20 final Set<String> savedUris; 21 + 22 + /// Maps post URI to its save type: 'local' or 'cloud'. 23 + final Map<String, String> saveTypeByUri; 20 24 final String? error; 21 25 22 26 bool isSaved(String postUri) => savedUris.contains(postUri); 23 27 28 + /// Returns the save type for [postUri], or null if not saved. 29 + String? saveTypeForUri(String postUri) => saveTypeByUri[postUri]; 30 + 24 31 SavedPostsState copyWith({ 25 32 SavedPostsStatus? status, 26 33 List<SavedPostEntry>? savedPosts, 27 34 Set<String>? savedUris, 35 + Map<String, String>? saveTypeByUri, 28 36 String? error, 29 37 }) { 30 38 return SavedPostsState( 31 39 status: status ?? this.status, 32 40 savedPosts: savedPosts ?? this.savedPosts, 33 41 savedUris: savedUris ?? this.savedUris, 42 + saveTypeByUri: saveTypeByUri ?? this.saveTypeByUri, 34 43 error: error ?? this.error, 35 44 ); 36 45 } 37 46 38 47 @override 39 - List<Object?> get props => [status, savedPosts, savedUris, error]; 48 + List<Object?> get props => [status, savedPosts, savedUris, saveTypeByUri, error]; 40 49 } 41 50 42 51 enum SavedPostsStatus { initial, loading, loaded, error } ··· 51 60 52 61 final AppDatabase _database; 53 62 final String _accountDid; 54 - StreamSubscription<Set<String>>? _savedUrisSubscription; 63 + StreamSubscription<Map<String, String>>? _savedUrisSubscription; 55 64 56 65 void _init() { 57 66 _savedUrisSubscription = _database 58 - .watchSavedPostUris(_accountDid) 67 + .watchSavedPostsWithType(_accountDid) 59 68 .listen( 60 - (uris) { 61 - emit(state.copyWith(savedUris: uris)); 69 + (typeByUri) { 70 + emit(state.copyWith(savedUris: typeByUri.keys.toSet(), saveTypeByUri: typeByUri)); 62 71 }, 63 72 onError: (error) { 64 73 log.e('Error watching saved post URIs', error: error); ··· 92 101 accountDid: Value(_accountDid), 93 102 postUri: Value(postUri), 94 103 postJson: Value(postJson), 104 + saveType: const Value('local'), 95 105 savedAt: Value(DateTime.now()), 96 106 ), 97 107 );
+7 -3
lib/features/feed/presentation/post_thread_screen.dart
··· 252 252 return BlocBuilder<SavedPostsCubit, SavedPostsState>( 253 253 builder: (context, savedState) { 254 254 return PostActionBar( 255 - replyCount: 0, 256 - repostCount: 0, 257 - likeCount: 0, 255 + replyCount: post.replyCount ?? 0, 256 + repostCount: postActionState.repostCount, 257 + likeCount: postActionState.likeCount, 258 + saveCount: post.bookmarkCount ?? 0, 258 259 isLiked: postActionState.isLiked, 259 260 isReposted: postActionState.isReposted, 260 261 isSaved: savedState.isSaved(post.uri.toString()), ··· 267 268 onQuote: () => _onQuote(context), 268 269 onLike: () => context.read<PostActionCubit>().toggleLike(), 269 270 onSave: () { 271 + unawaited(_onToggleSave(context)); 272 + }, 273 + onLongPressSave: () { 270 274 unawaited(_onToggleSave(context)); 271 275 }, 272 276 onMore: () => _showMoreOptions(context),
+42 -3
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 9 9 required this.replyCount, 10 10 required this.repostCount, 11 11 required this.likeCount, 12 + required this.saveCount, 12 13 required this.isLiked, 13 14 required this.isReposted, 14 15 required this.isSaved, ··· 20 21 this.onLike, 21 22 this.onShare, 22 23 this.onSave, 24 + this.onLongPressSave, 23 25 this.onMore, 24 26 this.isLoadingLike = false, 25 27 this.isLoadingRepost = false, ··· 28 30 final int replyCount; 29 31 final int repostCount; 30 32 final int likeCount; 33 + final int saveCount; 31 34 final bool isLiked; 32 35 final bool isReposted; 33 36 final bool isSaved; ··· 39 42 final VoidCallback? onLike; 40 43 final VoidCallback? onShare; 41 44 final VoidCallback? onSave; 45 + final VoidCallback? onLongPressSave; 42 46 final VoidCallback? onMore; 43 47 final bool isLoadingLike; 44 48 final bool isLoadingRepost; ··· 77 81 _ActionButton( 78 82 icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, 79 83 activeIcon: Icons.bookmark, 80 - count: 0, 84 + count: saveCount, 81 85 isActive: isSaved, 82 - onTap: onSave, 86 + onTap: onSave != null ? () => _showSaveOptions(context) : null, 87 + onLongPress: onLongPressSave, 83 88 color: Theme.of(context).colorScheme.onSurfaceVariant, 84 - activeColor: Theme.of(context).colorScheme.primary, 89 + activeColor: Colors.amber, 85 90 ), 86 91 _ActionButton( 87 92 icon: Icons.share_outlined, ··· 129 134 onQuote?.call(); 130 135 }, 131 136 ), 137 + ], 138 + ), 139 + ), 140 + ); 141 + } 142 + 143 + void _showSaveOptions(BuildContext context) { 144 + HapticFeedback.mediumImpact(); 145 + showModalBottomSheet<void>( 146 + context: context, 147 + builder: (context) => SafeArea( 148 + child: Column( 149 + mainAxisSize: MainAxisSize.min, 150 + children: [ 151 + ListTile( 152 + leading: Icon( 153 + isSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 154 + color: Colors.amber, 155 + ), 156 + title: Text(isSaved ? 'Remove local save' : 'Save locally'), 157 + onTap: () { 158 + Navigator.pop(context); 159 + onSave?.call(); 160 + }, 161 + ), 162 + ListTile( 163 + enabled: false, 164 + leading: Icon( 165 + Icons.cloud_outlined, 166 + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), 167 + ), 168 + title: const Text('Save to Bluesky'), 169 + subtitle: const Text('Coming soon'), 170 + ), 132 171 ], 133 172 ), 134 173 ),
+4
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 83 83 replyCount: post.replyCount ?? 0, 84 84 repostCount: postActionState.repostCount, 85 85 likeCount: postActionState.likeCount, 86 + saveCount: post.bookmarkCount ?? 0, 86 87 isLiked: postActionState.isLiked, 87 88 isReposted: postActionState.isReposted, 88 89 isSaved: savedState.isSaved(post.uri.toString()), ··· 95 96 onQuote: () => _onQuote(context), 96 97 onLike: () => context.read<PostActionCubit>().toggleLike(), 97 98 onSave: () { 99 + unawaited(_onToggleSave(context)); 100 + }, 101 + onLongPressSave: () { 98 102 unawaited(_onToggleSave(context)); 99 103 }, 100 104 onMore: () => _showMoreOptions(context),
+48
test/features/feed/cubit/saved_posts_cubit_test.dart
··· 243 243 expect(state.isSaved('uri1'), isTrue); 244 244 expect(state.isSaved('uri3'), isFalse); 245 245 }); 246 + 247 + test('saveTypeForUri returns correct save type', () { 248 + const state = SavedPostsState( 249 + status: SavedPostsStatus.loaded, 250 + savedUris: {'uri1'}, 251 + saveTypeByUri: {'uri1': 'local'}, 252 + ); 253 + 254 + expect(state.saveTypeForUri('uri1'), equals('local')); 255 + expect(state.saveTypeForUri('uri2'), isNull); 256 + }); 257 + 258 + test('saveTypeByUri is included in props', () { 259 + const state1 = SavedPostsState( 260 + status: SavedPostsStatus.loaded, 261 + savedUris: {'uri1'}, 262 + saveTypeByUri: {'uri1': 'local'}, 263 + ); 264 + const state2 = SavedPostsState( 265 + status: SavedPostsStatus.loaded, 266 + savedUris: {'uri1'}, 267 + saveTypeByUri: {'uri1': 'cloud'}, 268 + ); 269 + 270 + expect(state1, isNot(equals(state2))); 271 + }); 272 + }); 273 + 274 + group('saveType', () { 275 + test('toggleSave saves post with local saveType', () async { 276 + final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 277 + 278 + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 279 + 280 + final posts = await database.getSavedPosts(testAccountDid); 281 + expect(posts.length, 1); 282 + expect(posts.first.saveType, equals('local')); 283 + }); 284 + 285 + test('saveTypeForUri returns local after saving', () async { 286 + final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 287 + 288 + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 289 + await cubit.loadSavedPosts(); 290 + 291 + expect(cubit.state.saveTypeForUri(testPostUri1), equals('local')); 292 + expect(cubit.state.saveTypeForUri(testPostUri2), isNull); 293 + }); 246 294 }); 247 295 }); 248 296 }
+112
test/features/feed/presentation/post_action_bar_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; 4 + 5 + Widget _buildBar({ 6 + int replyCount = 0, 7 + int repostCount = 0, 8 + int likeCount = 0, 9 + int saveCount = 0, 10 + bool isLiked = false, 11 + bool isReposted = false, 12 + bool isSaved = false, 13 + VoidCallback? onSave, 14 + VoidCallback? onLongPressSave, 15 + VoidCallback? onRepost, 16 + VoidCallback? onLike, 17 + VoidCallback? onReply, 18 + }) { 19 + return MaterialApp( 20 + home: Scaffold( 21 + body: PostActionBar( 22 + replyCount: replyCount, 23 + repostCount: repostCount, 24 + likeCount: likeCount, 25 + saveCount: saveCount, 26 + isLiked: isLiked, 27 + isReposted: isReposted, 28 + isSaved: isSaved, 29 + postUri: 'at://did:plc:author/app.bsky.feed.post/abc123', 30 + onSave: onSave, 31 + onLongPressSave: onLongPressSave, 32 + onRepost: onRepost, 33 + onLike: onLike, 34 + onReply: onReply, 35 + ), 36 + ), 37 + ); 38 + } 39 + 40 + void main() { 41 + group('PostActionBar', () { 42 + testWidgets('renders with all counts', (tester) async { 43 + await tester.pumpWidget(_buildBar(replyCount: 3, repostCount: 7, likeCount: 42, saveCount: 5)); 44 + 45 + expect(find.text('3'), findsOneWidget); 46 + expect(find.text('7'), findsOneWidget); 47 + expect(find.text('42'), findsOneWidget); 48 + expect(find.text('5'), findsOneWidget); 49 + }); 50 + 51 + testWidgets('does not show saveCount when zero', (tester) async { 52 + await tester.pumpWidget(_buildBar(saveCount: 0)); 53 + 54 + // Count of 0 is not shown — only counts > 0 are rendered 55 + expect(find.text('0'), findsNothing); 56 + }); 57 + 58 + testWidgets('shows save count when > 0', (tester) async { 59 + await tester.pumpWidget(_buildBar(saveCount: 12)); 60 + expect(find.text('12'), findsOneWidget); 61 + }); 62 + 63 + testWidgets('long press bookmark calls onLongPressSave', (tester) async { 64 + var longPressCalled = false; 65 + await tester.pumpWidget(_buildBar(onLongPressSave: () => longPressCalled = true)); 66 + 67 + await tester.longPress(find.byIcon(Icons.bookmark_outline)); 68 + await tester.pump(); 69 + 70 + expect(longPressCalled, isTrue); 71 + }); 72 + 73 + testWidgets('tap bookmark shows save options bottom sheet', (tester) async { 74 + await tester.pumpWidget(_buildBar(onSave: () {})); 75 + 76 + await tester.tap(find.byIcon(Icons.bookmark_outline)); 77 + await tester.pumpAndSettle(); 78 + 79 + expect(find.text('Save locally'), findsOneWidget); 80 + expect(find.text('Save to Bluesky'), findsOneWidget); 81 + }); 82 + 83 + testWidgets('save menu shows Remove label when already saved', (tester) async { 84 + await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); 85 + 86 + await tester.tap(find.byIcon(Icons.bookmark)); 87 + await tester.pumpAndSettle(); 88 + 89 + expect(find.text('Remove local save'), findsOneWidget); 90 + }); 91 + 92 + testWidgets('tapping Save locally in menu calls onSave', (tester) async { 93 + var saveCalled = false; 94 + await tester.pumpWidget(_buildBar(onSave: () => saveCalled = true)); 95 + 96 + await tester.tap(find.byIcon(Icons.bookmark_outline)); 97 + await tester.pumpAndSettle(); 98 + 99 + await tester.tap(find.text('Save locally')); 100 + await tester.pumpAndSettle(); 101 + 102 + expect(saveCalled, isTrue); 103 + }); 104 + 105 + testWidgets('bookmark icon is amber when saved', (tester) async { 106 + await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); 107 + 108 + final icon = tester.widget<Icon>(find.byIcon(Icons.bookmark)); 109 + expect(icon.color, equals(Colors.amber)); 110 + }); 111 + }); 112 + }
+9 -6
test/features/feed/presentation/saved_posts_screen_test.dart
··· 43 43 accountDid: 'did:plc:me', 44 44 postUri: postUri, 45 45 postJson: postJson, 46 + saveType: 'local', 46 47 savedAt: DateTime.utc(2026, 3, 15), 47 48 ); 48 49 } ··· 58 59 mockPostActionRepository = MockPostActionRepository(); 59 60 60 61 // Default stubs: empty saved posts 61 - when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({})); 62 + when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); 62 63 when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); 63 64 }); 64 65 ··· 89 90 90 91 when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); 91 92 when( 92 - () => mockDatabase.watchSavedPostUris(testAccountDid), 93 - ).thenAnswer((_) => Stream.value({postView.uri.toString()})); 93 + () => mockDatabase.watchSavedPostsWithType(testAccountDid), 94 + ).thenAnswer((_) => Stream.value({postView.uri.toString(): 'local'})); 94 95 95 96 await tester.pumpWidget(buildSubject()); 96 97 await tester.pump(); ··· 102 103 final entry = _makeEntry(postJson: 'not valid json {{{'); 103 104 104 105 when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); 105 - when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({entry.postUri})); 106 + when( 107 + () => mockDatabase.watchSavedPostsWithType(testAccountDid), 108 + ).thenAnswer((_) => Stream.value({entry.postUri: 'local'})); 106 109 107 110 await tester.pumpWidget(buildSubject()); 108 111 await tester.pump(); ··· 122 125 return Future.value(callCount == 1 ? [entry] : <SavedPostEntry>[]); 123 126 }); 124 127 when( 125 - () => mockDatabase.watchSavedPostUris(testAccountDid), 126 - ).thenAnswer((_) => Stream.value({postView.uri.toString()})); 128 + () => mockDatabase.watchSavedPostsWithType(testAccountDid), 129 + ).thenAnswer((_) => Stream.value({postView.uri.toString(): 'local'})); 127 130 when(() => mockDatabase.unsavePostById(entry.id)).thenAnswer((_) => Future.value(1)); 128 131 129 132 await tester.pumpWidget(buildSubject());