From 6eb3a4556bc719104a94fe58fff1114c7cd7f1ff Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 17 Mar 2026 22:48:39 -0500 Subject: [PATCH] feat: save type for posts * add options to save UI --- docs/BUGS.md | 4 +- lib/core/database/app_database.dart | 11 +- lib/core/database/app_database.g.dart | 75 ++++++++++-- lib/core/database/tables.dart | 1 + .../feed/cubit/saved_posts_cubit.dart | 20 +++- .../feed/presentation/post_thread_screen.dart | 10 +- .../presentation/widgets/post_action_bar.dart | 45 ++++++- .../widgets/post_card_with_actions.dart | 4 + .../feed/cubit/saved_posts_cubit_test.dart | 48 ++++++++ .../presentation/post_action_bar_test.dart | 112 ++++++++++++++++++ .../presentation/saved_posts_screen_test.dart | 15 ++- 11 files changed, 315 insertions(+), 30 deletions(-) create mode 100644 test/features/feed/presentation/post_action_bar_test.dart diff --git a/docs/BUGS.md b/docs/BUGS.md index eea0f5c..72b0bae 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -13,8 +13,8 @@ updated: 2026-03-17 - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) - [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) -- [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) -- [ ] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) +- [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) +- [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) - [ ] [12. Delete Post — Remove from Feed](#12-delete-post--remove-from-feed) diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 33d7622..ee95716 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -11,7 +11,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -38,6 +38,9 @@ class AppDatabase extends _$AppDatabase { if (from < 6) { await migrator.createTable(savedPosts); } + if (from < 7) { + await migrator.addColumn(savedPosts, savedPosts.saveType); + } }, ); @@ -270,4 +273,10 @@ class AppDatabase extends _$AppDatabase { savedPosts, )..where((s) => s.accountDid.equals(accountDid))).watch().map((posts) => posts.map((p) => p.postUri).toSet()); } + + Stream> watchSavedPostsWithType(String accountDid) { + return (select(savedPosts)..where((s) => s.accountDid.equals(accountDid))).watch().map( + (posts) => {for (final p in posts) p.postUri: p.saveType}, + ); + } } diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 10cff90..bf756f5 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -2709,6 +2709,16 @@ class $SavedPostsTable extends SavedPosts with TableInfo<$SavedPostsTable, Saved type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _saveTypeMeta = const VerificationMeta('saveType'); + @override + late final GeneratedColumn saveType = GeneratedColumn( + 'save_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('local'), + ); static const VerificationMeta _savedAtMeta = const VerificationMeta('savedAt'); @override late final GeneratedColumn savedAt = GeneratedColumn( @@ -2720,7 +2730,7 @@ class $SavedPostsTable extends SavedPosts with TableInfo<$SavedPostsTable, Saved defaultValue: currentDateAndTime, ); @override - List get $columns => [id, accountDid, postUri, postJson, savedAt]; + List get $columns => [id, accountDid, postUri, postJson, saveType, savedAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -2748,6 +2758,9 @@ class $SavedPostsTable extends SavedPosts with TableInfo<$SavedPostsTable, Saved } else if (isInserting) { context.missing(_postJsonMeta); } + if (data.containsKey('save_type')) { + context.handle(_saveTypeMeta, saveType.isAcceptableOrUnknown(data['save_type']!, _saveTypeMeta)); + } if (data.containsKey('saved_at')) { context.handle(_savedAtMeta, savedAt.isAcceptableOrUnknown(data['saved_at']!, _savedAtMeta)); } @@ -2764,6 +2777,7 @@ class $SavedPostsTable extends SavedPosts with TableInfo<$SavedPostsTable, Saved accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, postUri: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}post_uri'])!, postJson: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}post_json'])!, + saveType: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}save_type'])!, savedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}saved_at'])!, ); } @@ -2779,12 +2793,14 @@ class SavedPostEntry extends DataClass implements Insertable { final String accountDid; final String postUri; final String postJson; + final String saveType; final DateTime savedAt; const SavedPostEntry({ required this.id, required this.accountDid, required this.postUri, required this.postJson, + required this.saveType, required this.savedAt, }); @override @@ -2794,6 +2810,7 @@ class SavedPostEntry extends DataClass implements Insertable { map['account_did'] = Variable(accountDid); map['post_uri'] = Variable(postUri); map['post_json'] = Variable(postJson); + map['save_type'] = Variable(saveType); map['saved_at'] = Variable(savedAt); return map; } @@ -2804,6 +2821,7 @@ class SavedPostEntry extends DataClass implements Insertable { accountDid: Value(accountDid), postUri: Value(postUri), postJson: Value(postJson), + saveType: Value(saveType), savedAt: Value(savedAt), ); } @@ -2815,6 +2833,7 @@ class SavedPostEntry extends DataClass implements Insertable { accountDid: serializer.fromJson(json['accountDid']), postUri: serializer.fromJson(json['postUri']), postJson: serializer.fromJson(json['postJson']), + saveType: serializer.fromJson(json['saveType']), savedAt: serializer.fromJson(json['savedAt']), ); } @@ -2826,24 +2845,33 @@ class SavedPostEntry extends DataClass implements Insertable { 'accountDid': serializer.toJson(accountDid), 'postUri': serializer.toJson(postUri), 'postJson': serializer.toJson(postJson), + 'saveType': serializer.toJson(saveType), 'savedAt': serializer.toJson(savedAt), }; } - SavedPostEntry copyWith({int? id, String? accountDid, String? postUri, String? postJson, DateTime? savedAt}) => - SavedPostEntry( - id: id ?? this.id, - accountDid: accountDid ?? this.accountDid, - postUri: postUri ?? this.postUri, - postJson: postJson ?? this.postJson, - savedAt: savedAt ?? this.savedAt, - ); + SavedPostEntry copyWith({ + int? id, + String? accountDid, + String? postUri, + String? postJson, + String? saveType, + DateTime? savedAt, + }) => SavedPostEntry( + id: id ?? this.id, + accountDid: accountDid ?? this.accountDid, + postUri: postUri ?? this.postUri, + postJson: postJson ?? this.postJson, + saveType: saveType ?? this.saveType, + savedAt: savedAt ?? this.savedAt, + ); SavedPostEntry copyWithCompanion(SavedPostsCompanion data) { return SavedPostEntry( id: data.id.present ? data.id.value : this.id, accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, postUri: data.postUri.present ? data.postUri.value : this.postUri, postJson: data.postJson.present ? data.postJson.value : this.postJson, + saveType: data.saveType.present ? data.saveType.value : this.saveType, savedAt: data.savedAt.present ? data.savedAt.value : this.savedAt, ); } @@ -2855,13 +2883,14 @@ class SavedPostEntry extends DataClass implements Insertable { ..write('accountDid: $accountDid, ') ..write('postUri: $postUri, ') ..write('postJson: $postJson, ') + ..write('saveType: $saveType, ') ..write('savedAt: $savedAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, accountDid, postUri, postJson, savedAt); + int get hashCode => Object.hash(id, accountDid, postUri, postJson, saveType, savedAt); @override bool operator ==(Object other) => identical(this, other) || @@ -2870,6 +2899,7 @@ class SavedPostEntry extends DataClass implements Insertable { other.accountDid == this.accountDid && other.postUri == this.postUri && other.postJson == this.postJson && + other.saveType == this.saveType && other.savedAt == this.savedAt); } @@ -2878,12 +2908,14 @@ class SavedPostsCompanion extends UpdateCompanion { final Value accountDid; final Value postUri; final Value postJson; + final Value saveType; final Value savedAt; const SavedPostsCompanion({ this.id = const Value.absent(), this.accountDid = const Value.absent(), this.postUri = const Value.absent(), this.postJson = const Value.absent(), + this.saveType = const Value.absent(), this.savedAt = const Value.absent(), }); SavedPostsCompanion.insert({ @@ -2891,6 +2923,7 @@ class SavedPostsCompanion extends UpdateCompanion { required String accountDid, required String postUri, required String postJson, + this.saveType = const Value.absent(), this.savedAt = const Value.absent(), }) : accountDid = Value(accountDid), postUri = Value(postUri), @@ -2900,6 +2933,7 @@ class SavedPostsCompanion extends UpdateCompanion { Expression? accountDid, Expression? postUri, Expression? postJson, + Expression? saveType, Expression? savedAt, }) { return RawValuesInsertable({ @@ -2907,6 +2941,7 @@ class SavedPostsCompanion extends UpdateCompanion { if (accountDid != null) 'account_did': accountDid, if (postUri != null) 'post_uri': postUri, if (postJson != null) 'post_json': postJson, + if (saveType != null) 'save_type': saveType, if (savedAt != null) 'saved_at': savedAt, }); } @@ -2916,6 +2951,7 @@ class SavedPostsCompanion extends UpdateCompanion { Value? accountDid, Value? postUri, Value? postJson, + Value? saveType, Value? savedAt, }) { return SavedPostsCompanion( @@ -2923,6 +2959,7 @@ class SavedPostsCompanion extends UpdateCompanion { accountDid: accountDid ?? this.accountDid, postUri: postUri ?? this.postUri, postJson: postJson ?? this.postJson, + saveType: saveType ?? this.saveType, savedAt: savedAt ?? this.savedAt, ); } @@ -2942,6 +2979,9 @@ class SavedPostsCompanion extends UpdateCompanion { if (postJson.present) { map['post_json'] = Variable(postJson.value); } + if (saveType.present) { + map['save_type'] = Variable(saveType.value); + } if (savedAt.present) { map['saved_at'] = Variable(savedAt.value); } @@ -2955,6 +2995,7 @@ class SavedPostsCompanion extends UpdateCompanion { ..write('accountDid: $accountDid, ') ..write('postUri: $postUri, ') ..write('postJson: $postJson, ') + ..write('saveType: $saveType, ') ..write('savedAt: $savedAt') ..write(')')) .toString(); @@ -4270,6 +4311,7 @@ typedef $$SavedPostsTableCreateCompanionBuilder = required String accountDid, required String postUri, required String postJson, + Value saveType, Value savedAt, }); typedef $$SavedPostsTableUpdateCompanionBuilder = @@ -4278,6 +4320,7 @@ typedef $$SavedPostsTableUpdateCompanionBuilder = Value accountDid, Value postUri, Value postJson, + Value saveType, Value savedAt, }); @@ -4300,6 +4343,9 @@ class $$SavedPostsTableFilterComposer extends Composer<_$AppDatabase, $SavedPost ColumnFilters get postJson => $composableBuilder(column: $table.postJson, builder: (column) => ColumnFilters(column)); + ColumnFilters get saveType => + $composableBuilder(column: $table.saveType, builder: (column) => ColumnFilters(column)); + ColumnFilters get savedAt => $composableBuilder(column: $table.savedAt, builder: (column) => ColumnFilters(column)); } @@ -4323,6 +4369,9 @@ class $$SavedPostsTableOrderingComposer extends Composer<_$AppDatabase, $SavedPo ColumnOrderings get postJson => $composableBuilder(column: $table.postJson, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get saveType => + $composableBuilder(column: $table.saveType, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get savedAt => $composableBuilder(column: $table.savedAt, builder: (column) => ColumnOrderings(column)); } @@ -4343,6 +4392,8 @@ class $$SavedPostsTableAnnotationComposer extends Composer<_$AppDatabase, $Saved GeneratedColumn get postJson => $composableBuilder(column: $table.postJson, builder: (column) => column); + GeneratedColumn get saveType => $composableBuilder(column: $table.saveType, builder: (column) => column); + GeneratedColumn get savedAt => $composableBuilder(column: $table.savedAt, builder: (column) => column); } @@ -4375,12 +4426,14 @@ class $$SavedPostsTableTableManager Value accountDid = const Value.absent(), Value postUri = const Value.absent(), Value postJson = const Value.absent(), + Value saveType = const Value.absent(), Value savedAt = const Value.absent(), }) => SavedPostsCompanion( id: id, accountDid: accountDid, postUri: postUri, postJson: postJson, + saveType: saveType, savedAt: savedAt, ), createCompanionCallback: @@ -4389,12 +4442,14 @@ class $$SavedPostsTableTableManager required String accountDid, required String postUri, required String postJson, + Value saveType = const Value.absent(), Value savedAt = const Value.absent(), }) => SavedPostsCompanion.insert( id: id, accountDid: accountDid, postUri: postUri, postJson: postJson, + saveType: saveType, savedAt: savedAt, ), withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index e4f0132..e72c7b5 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -97,6 +97,7 @@ class SavedPosts extends Table { TextColumn get accountDid => text()(); TextColumn get postUri => text()(); TextColumn get postJson => text()(); + TextColumn get saveType => text().withDefault(const Constant('local'))(); DateTimeColumn get savedAt => dateTime().withDefault(currentDateAndTime)(); @override diff --git a/lib/features/feed/cubit/saved_posts_cubit.dart b/lib/features/feed/cubit/saved_posts_cubit.dart index e318185..ea5fcdb 100644 --- a/lib/features/feed/cubit/saved_posts_cubit.dart +++ b/lib/features/feed/cubit/saved_posts_cubit.dart @@ -11,32 +11,41 @@ class SavedPostsState extends Equatable { this.status = SavedPostsStatus.initial, this.savedPosts = const [], this.savedUris = const {}, + this.saveTypeByUri = const {}, this.error, }); final SavedPostsStatus status; final List savedPosts; final Set savedUris; + + /// Maps post URI to its save type: 'local' or 'cloud'. + final Map saveTypeByUri; final String? error; bool isSaved(String postUri) => savedUris.contains(postUri); + /// Returns the save type for [postUri], or null if not saved. + String? saveTypeForUri(String postUri) => saveTypeByUri[postUri]; + SavedPostsState copyWith({ SavedPostsStatus? status, List? savedPosts, Set? savedUris, + Map? saveTypeByUri, String? error, }) { return SavedPostsState( status: status ?? this.status, savedPosts: savedPosts ?? this.savedPosts, savedUris: savedUris ?? this.savedUris, + saveTypeByUri: saveTypeByUri ?? this.saveTypeByUri, error: error ?? this.error, ); } @override - List get props => [status, savedPosts, savedUris, error]; + List get props => [status, savedPosts, savedUris, saveTypeByUri, error]; } enum SavedPostsStatus { initial, loading, loaded, error } @@ -51,14 +60,14 @@ class SavedPostsCubit extends Cubit { final AppDatabase _database; final String _accountDid; - StreamSubscription>? _savedUrisSubscription; + StreamSubscription>? _savedUrisSubscription; void _init() { _savedUrisSubscription = _database - .watchSavedPostUris(_accountDid) + .watchSavedPostsWithType(_accountDid) .listen( - (uris) { - emit(state.copyWith(savedUris: uris)); + (typeByUri) { + emit(state.copyWith(savedUris: typeByUri.keys.toSet(), saveTypeByUri: typeByUri)); }, onError: (error) { log.e('Error watching saved post URIs', error: error); @@ -92,6 +101,7 @@ class SavedPostsCubit extends Cubit { accountDid: Value(_accountDid), postUri: Value(postUri), postJson: Value(postJson), + saveType: const Value('local'), savedAt: Value(DateTime.now()), ), ); diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 1952606..29bb2d6 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -252,9 +252,10 @@ class _FocusedPostContent extends StatelessWidget { return BlocBuilder( builder: (context, savedState) { return PostActionBar( - replyCount: 0, - repostCount: 0, - likeCount: 0, + replyCount: post.replyCount ?? 0, + repostCount: postActionState.repostCount, + likeCount: postActionState.likeCount, + saveCount: post.bookmarkCount ?? 0, isLiked: postActionState.isLiked, isReposted: postActionState.isReposted, isSaved: savedState.isSaved(post.uri.toString()), @@ -269,6 +270,9 @@ class _FocusedPostContent extends StatelessWidget { onSave: () { unawaited(_onToggleSave(context)); }, + onLongPressSave: () { + unawaited(_onToggleSave(context)); + }, onMore: () => _showMoreOptions(context), ); }, diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 6d492cb..69f2641 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -9,6 +9,7 @@ class PostActionBar extends StatelessWidget { required this.replyCount, required this.repostCount, required this.likeCount, + required this.saveCount, required this.isLiked, required this.isReposted, required this.isSaved, @@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget { this.onLike, this.onShare, this.onSave, + this.onLongPressSave, this.onMore, this.isLoadingLike = false, this.isLoadingRepost = false, @@ -28,6 +30,7 @@ class PostActionBar extends StatelessWidget { final int replyCount; final int repostCount; final int likeCount; + final int saveCount; final bool isLiked; final bool isReposted; final bool isSaved; @@ -39,6 +42,7 @@ class PostActionBar extends StatelessWidget { final VoidCallback? onLike; final VoidCallback? onShare; final VoidCallback? onSave; + final VoidCallback? onLongPressSave; final VoidCallback? onMore; final bool isLoadingLike; final bool isLoadingRepost; @@ -77,11 +81,12 @@ class PostActionBar extends StatelessWidget { _ActionButton( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, activeIcon: Icons.bookmark, - count: 0, + count: saveCount, isActive: isSaved, - onTap: onSave, + onTap: onSave != null ? () => _showSaveOptions(context) : null, + onLongPress: onLongPressSave, color: Theme.of(context).colorScheme.onSurfaceVariant, - activeColor: Theme.of(context).colorScheme.primary, + activeColor: Colors.amber, ), _ActionButton( icon: Icons.share_outlined, @@ -135,6 +140,40 @@ class PostActionBar extends StatelessWidget { ); } + void _showSaveOptions(BuildContext context) { + HapticFeedback.mediumImpact(); + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + isSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, + color: Colors.amber, + ), + title: Text(isSaved ? 'Remove local save' : 'Save locally'), + onTap: () { + Navigator.pop(context); + onSave?.call(); + }, + ), + ListTile( + enabled: false, + leading: Icon( + Icons.cloud_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + title: const Text('Save to Bluesky'), + subtitle: const Text('Coming soon'), + ), + ], + ), + ), + ); + } + Future _defaultShare(BuildContext context) async { final url = _convertAtUriToBskyUrl(postUri); await Share.share(url); diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index 33bd782..9772bc4 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -83,6 +83,7 @@ class _PostCardWithActionsContent extends StatelessWidget { replyCount: post.replyCount ?? 0, repostCount: postActionState.repostCount, likeCount: postActionState.likeCount, + saveCount: post.bookmarkCount ?? 0, isLiked: postActionState.isLiked, isReposted: postActionState.isReposted, isSaved: savedState.isSaved(post.uri.toString()), @@ -97,6 +98,9 @@ class _PostCardWithActionsContent extends StatelessWidget { onSave: () { unawaited(_onToggleSave(context)); }, + onLongPressSave: () { + unawaited(_onToggleSave(context)); + }, onMore: () => _showMoreOptions(context), ); }, diff --git a/test/features/feed/cubit/saved_posts_cubit_test.dart b/test/features/feed/cubit/saved_posts_cubit_test.dart index a1fd504..400642f 100644 --- a/test/features/feed/cubit/saved_posts_cubit_test.dart +++ b/test/features/feed/cubit/saved_posts_cubit_test.dart @@ -243,6 +243,54 @@ void main() { expect(state.isSaved('uri1'), isTrue); expect(state.isSaved('uri3'), isFalse); }); + + test('saveTypeForUri returns correct save type', () { + const state = SavedPostsState( + status: SavedPostsStatus.loaded, + savedUris: {'uri1'}, + saveTypeByUri: {'uri1': 'local'}, + ); + + expect(state.saveTypeForUri('uri1'), equals('local')); + expect(state.saveTypeForUri('uri2'), isNull); + }); + + test('saveTypeByUri is included in props', () { + const state1 = SavedPostsState( + status: SavedPostsStatus.loaded, + savedUris: {'uri1'}, + saveTypeByUri: {'uri1': 'local'}, + ); + const state2 = SavedPostsState( + status: SavedPostsStatus.loaded, + savedUris: {'uri1'}, + saveTypeByUri: {'uri1': 'cloud'}, + ); + + expect(state1, isNot(equals(state2))); + }); + }); + + group('saveType', () { + test('toggleSave saves post with local saveType', () async { + final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.length, 1); + expect(posts.first.saveType, equals('local')); + }); + + test('saveTypeForUri returns local after saving', () async { + final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.loadSavedPosts(); + + expect(cubit.state.saveTypeForUri(testPostUri1), equals('local')); + expect(cubit.state.saveTypeForUri(testPostUri2), isNull); + }); }); }); } diff --git a/test/features/feed/presentation/post_action_bar_test.dart b/test/features/feed/presentation/post_action_bar_test.dart new file mode 100644 index 0000000..39f2143 --- /dev/null +++ b/test/features/feed/presentation/post_action_bar_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; + +Widget _buildBar({ + int replyCount = 0, + int repostCount = 0, + int likeCount = 0, + int saveCount = 0, + bool isLiked = false, + bool isReposted = false, + bool isSaved = false, + VoidCallback? onSave, + VoidCallback? onLongPressSave, + VoidCallback? onRepost, + VoidCallback? onLike, + VoidCallback? onReply, +}) { + return MaterialApp( + home: Scaffold( + body: PostActionBar( + replyCount: replyCount, + repostCount: repostCount, + likeCount: likeCount, + saveCount: saveCount, + isLiked: isLiked, + isReposted: isReposted, + isSaved: isSaved, + postUri: 'at://did:plc:author/app.bsky.feed.post/abc123', + onSave: onSave, + onLongPressSave: onLongPressSave, + onRepost: onRepost, + onLike: onLike, + onReply: onReply, + ), + ), + ); +} + +void main() { + group('PostActionBar', () { + testWidgets('renders with all counts', (tester) async { + await tester.pumpWidget(_buildBar(replyCount: 3, repostCount: 7, likeCount: 42, saveCount: 5)); + + expect(find.text('3'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }); + + testWidgets('does not show saveCount when zero', (tester) async { + await tester.pumpWidget(_buildBar(saveCount: 0)); + + // Count of 0 is not shown — only counts > 0 are rendered + expect(find.text('0'), findsNothing); + }); + + testWidgets('shows save count when > 0', (tester) async { + await tester.pumpWidget(_buildBar(saveCount: 12)); + expect(find.text('12'), findsOneWidget); + }); + + testWidgets('long press bookmark calls onLongPressSave', (tester) async { + var longPressCalled = false; + await tester.pumpWidget(_buildBar(onLongPressSave: () => longPressCalled = true)); + + await tester.longPress(find.byIcon(Icons.bookmark_outline)); + await tester.pump(); + + expect(longPressCalled, isTrue); + }); + + testWidgets('tap bookmark shows save options bottom sheet', (tester) async { + await tester.pumpWidget(_buildBar(onSave: () {})); + + await tester.tap(find.byIcon(Icons.bookmark_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Save locally'), findsOneWidget); + expect(find.text('Save to Bluesky'), findsOneWidget); + }); + + testWidgets('save menu shows Remove label when already saved', (tester) async { + await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); + + await tester.tap(find.byIcon(Icons.bookmark)); + await tester.pumpAndSettle(); + + expect(find.text('Remove local save'), findsOneWidget); + }); + + testWidgets('tapping Save locally in menu calls onSave', (tester) async { + var saveCalled = false; + await tester.pumpWidget(_buildBar(onSave: () => saveCalled = true)); + + await tester.tap(find.byIcon(Icons.bookmark_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save locally')); + await tester.pumpAndSettle(); + + expect(saveCalled, isTrue); + }); + + testWidgets('bookmark icon is amber when saved', (tester) async { + await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); + + final icon = tester.widget(find.byIcon(Icons.bookmark)); + expect(icon.color, equals(Colors.amber)); + }); + }); +} diff --git a/test/features/feed/presentation/saved_posts_screen_test.dart b/test/features/feed/presentation/saved_posts_screen_test.dart index 8e9ce0f..93ff948 100644 --- a/test/features/feed/presentation/saved_posts_screen_test.dart +++ b/test/features/feed/presentation/saved_posts_screen_test.dart @@ -43,6 +43,7 @@ SavedPostEntry _makeEntry({ accountDid: 'did:plc:me', postUri: postUri, postJson: postJson, + saveType: 'local', savedAt: DateTime.utc(2026, 3, 15), ); } @@ -58,7 +59,7 @@ void main() { mockPostActionRepository = MockPostActionRepository(); // Default stubs: empty saved posts - when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({})); + when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); }); @@ -89,8 +90,8 @@ void main() { when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); when( - () => mockDatabase.watchSavedPostUris(testAccountDid), - ).thenAnswer((_) => Stream.value({postView.uri.toString()})); + () => mockDatabase.watchSavedPostsWithType(testAccountDid), + ).thenAnswer((_) => Stream.value({postView.uri.toString(): 'local'})); await tester.pumpWidget(buildSubject()); await tester.pump(); @@ -102,7 +103,9 @@ void main() { final entry = _makeEntry(postJson: 'not valid json {{{'); when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); - when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({entry.postUri})); + when( + () => mockDatabase.watchSavedPostsWithType(testAccountDid), + ).thenAnswer((_) => Stream.value({entry.postUri: 'local'})); await tester.pumpWidget(buildSubject()); await tester.pump(); @@ -122,8 +125,8 @@ void main() { return Future.value(callCount == 1 ? [entry] : []); }); when( - () => mockDatabase.watchSavedPostUris(testAccountDid), - ).thenAnswer((_) => Stream.value({postView.uri.toString()})); + () => mockDatabase.watchSavedPostsWithType(testAccountDid), + ).thenAnswer((_) => Stream.value({postView.uri.toString(): 'local'})); when(() => mockDatabase.unsavePostById(entry.id)).thenAnswer((_) => Future.value(1)); await tester.pumpWidget(buildSubject()); -- 2.51.2