+1
-1
lib/src/features/feeds/application/feed_content_notifier.g.dart
+1
-1
lib/src/features/feeds/application/feed_content_notifier.g.dart
+3
-3
lib/src/features/feeds/application/feed_providers.g.dart
+3
-3
lib/src/features/feeds/application/feed_providers.g.dart
···
78
78
AllFeedsNotifier create() => AllFeedsNotifier();
79
79
}
80
80
81
-
String _$allFeedsNotifierHash() => r'150e08e3ea64c0a368a0d0515eb73edadb1bfe4e';
81
+
String _$allFeedsNotifierHash() => r'9c7c7493ab2f646d37af9b8fedbaba49336be33e';
82
82
83
83
/// Notifier for watching all saved feeds reactively.
84
84
···
128
128
PinnedFeedsNotifier create() => PinnedFeedsNotifier();
129
129
}
130
130
131
-
String _$pinnedFeedsNotifierHash() => r'b21dee72c84c29e80e0f073b019e768fd7952a67';
131
+
String _$pinnedFeedsNotifierHash() => r'ee2ab5b56dc7437709f959204e7b2d88a28c2ef6';
132
132
133
133
/// Notifier for watching pinned feeds reactively.
134
134
···
385
385
}
386
386
}
387
387
388
-
String _$activeFeedHash() => r'37de39d7b43cf52c4aa886da362bf217116c6874';
388
+
String _$activeFeedHash() => r'b68c4cb42ba06f9b57ad01bc4a7f891690948013';
389
389
390
390
/// Notifier for tracking the currently active feed.
391
391
///
+62
lib/src/features/feeds/application/post_interaction_providers.dart
+62
lib/src/features/feeds/application/post_interaction_providers.dart
···
1
+
import 'package:lazurite/src/app/providers.dart';
2
+
import 'package:lazurite/src/core/utils/logger_provider.dart';
3
+
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
4
+
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
5
+
import 'package:lazurite/src/features/feeds/infrastructure/post_interaction_repository.dart';
6
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
7
+
import 'package:lazurite/src/infrastructure/network/providers.dart';
8
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
9
+
10
+
part 'post_interaction_providers.g.dart';
11
+
12
+
/// Domain model for post interaction state (mirrors PostInteraction entity).
13
+
class PostInteractionData {
14
+
PostInteractionData({
15
+
required this.postUri,
16
+
this.likeUri,
17
+
this.repostUri,
18
+
this.bookmarkUri,
19
+
required this.bookmarked,
20
+
required this.threadMuted,
21
+
});
22
+
23
+
final String postUri;
24
+
final String? likeUri;
25
+
final String? repostUri;
26
+
final String? bookmarkUri;
27
+
final bool bookmarked;
28
+
final bool threadMuted;
29
+
30
+
static PostInteractionData fromEntity(PostInteraction entity) {
31
+
return PostInteractionData(
32
+
postUri: entity.postUri,
33
+
likeUri: entity.likeUri,
34
+
repostUri: entity.repostUri,
35
+
bookmarkUri: entity.bookmarkUri,
36
+
bookmarked: entity.bookmarked,
37
+
threadMuted: entity.threadMuted,
38
+
);
39
+
}
40
+
}
41
+
42
+
@riverpod
43
+
PostInteractionRepository postInteractionRepository(Ref ref) {
44
+
return PostInteractionRepository(
45
+
ref.watch(xrpcClientProvider),
46
+
ref.watch(appDatabaseProvider).postInteractionsDao,
47
+
ref.watch(loggerProvider('PostInteractionRepository')),
48
+
);
49
+
}
50
+
51
+
@riverpod
52
+
Stream<PostInteractionData?> postInteractionState(Ref ref, String postUri) {
53
+
final authState = ref.watch(authProvider);
54
+
final ownerDid = (authState is AuthStateAuthenticated) ? authState.session.did : null;
55
+
if (ownerDid == null) return Stream.value(null);
56
+
57
+
final dao = ref.watch(appDatabaseProvider).postInteractionsDao;
58
+
return dao.watchInteraction(postUri, ownerDid).map((entity) {
59
+
if (entity == null) return null;
60
+
return PostInteractionData.fromEntity(entity);
61
+
});
62
+
}
+130
lib/src/features/feeds/application/post_interaction_providers.g.dart
+130
lib/src/features/feeds/application/post_interaction_providers.g.dart
···
1
+
// GENERATED CODE - DO NOT MODIFY BY HAND
2
+
3
+
part of 'post_interaction_providers.dart';
4
+
5
+
// **************************************************************************
6
+
// RiverpodGenerator
7
+
// **************************************************************************
8
+
9
+
// GENERATED CODE - DO NOT MODIFY BY HAND
10
+
// ignore_for_file: type=lint, type=warning
11
+
12
+
@ProviderFor(postInteractionRepository)
13
+
final postInteractionRepositoryProvider = PostInteractionRepositoryProvider._();
14
+
15
+
final class PostInteractionRepositoryProvider
16
+
extends
17
+
$FunctionalProvider<
18
+
PostInteractionRepository,
19
+
PostInteractionRepository,
20
+
PostInteractionRepository
21
+
>
22
+
with $Provider<PostInteractionRepository> {
23
+
PostInteractionRepositoryProvider._()
24
+
: super(
25
+
from: null,
26
+
argument: null,
27
+
retry: null,
28
+
name: r'postInteractionRepositoryProvider',
29
+
isAutoDispose: true,
30
+
dependencies: null,
31
+
$allTransitiveDependencies: null,
32
+
);
33
+
34
+
@override
35
+
String debugGetCreateSourceHash() => _$postInteractionRepositoryHash();
36
+
37
+
@$internal
38
+
@override
39
+
$ProviderElement<PostInteractionRepository> $createElement($ProviderPointer pointer) =>
40
+
$ProviderElement(pointer);
41
+
42
+
@override
43
+
PostInteractionRepository create(Ref ref) {
44
+
return postInteractionRepository(ref);
45
+
}
46
+
47
+
/// {@macro riverpod.override_with_value}
48
+
Override overrideWithValue(PostInteractionRepository value) {
49
+
return $ProviderOverride(
50
+
origin: this,
51
+
providerOverride: $SyncValueProvider<PostInteractionRepository>(value),
52
+
);
53
+
}
54
+
}
55
+
56
+
String _$postInteractionRepositoryHash() => r'1b78f513934cb5a5d8634fa72f1f59ee8744232a';
57
+
58
+
@ProviderFor(postInteractionState)
59
+
final postInteractionStateProvider = PostInteractionStateFamily._();
60
+
61
+
final class PostInteractionStateProvider
62
+
extends
63
+
$FunctionalProvider<
64
+
AsyncValue<PostInteractionData?>,
65
+
PostInteractionData?,
66
+
Stream<PostInteractionData?>
67
+
>
68
+
with $FutureModifier<PostInteractionData?>, $StreamProvider<PostInteractionData?> {
69
+
PostInteractionStateProvider._({
70
+
required PostInteractionStateFamily super.from,
71
+
required String super.argument,
72
+
}) : super(
73
+
retry: null,
74
+
name: r'postInteractionStateProvider',
75
+
isAutoDispose: true,
76
+
dependencies: null,
77
+
$allTransitiveDependencies: null,
78
+
);
79
+
80
+
@override
81
+
String debugGetCreateSourceHash() => _$postInteractionStateHash();
82
+
83
+
@override
84
+
String toString() {
85
+
return r'postInteractionStateProvider'
86
+
''
87
+
'($argument)';
88
+
}
89
+
90
+
@$internal
91
+
@override
92
+
$StreamProviderElement<PostInteractionData?> $createElement($ProviderPointer pointer) =>
93
+
$StreamProviderElement(pointer);
94
+
95
+
@override
96
+
Stream<PostInteractionData?> create(Ref ref) {
97
+
final argument = this.argument as String;
98
+
return postInteractionState(ref, argument);
99
+
}
100
+
101
+
@override
102
+
bool operator ==(Object other) {
103
+
return other is PostInteractionStateProvider && other.argument == argument;
104
+
}
105
+
106
+
@override
107
+
int get hashCode {
108
+
return argument.hashCode;
109
+
}
110
+
}
111
+
112
+
String _$postInteractionStateHash() => r'7bee7e79699aa2f5295dd1bcee5d207634b9a6eb';
113
+
114
+
final class PostInteractionStateFamily extends $Family
115
+
with $FunctionalFamilyOverride<Stream<PostInteractionData?>, String> {
116
+
PostInteractionStateFamily._()
117
+
: super(
118
+
retry: null,
119
+
name: r'postInteractionStateProvider',
120
+
dependencies: null,
121
+
$allTransitiveDependencies: null,
122
+
isAutoDispose: true,
123
+
);
124
+
125
+
PostInteractionStateProvider call(String postUri) =>
126
+
PostInteractionStateProvider._(argument: postUri, from: this);
127
+
128
+
@override
129
+
String toString() => r'postInteractionStateProvider';
130
+
}
+271
lib/src/features/feeds/infrastructure/post_interaction_repository.dart
+271
lib/src/features/feeds/infrastructure/post_interaction_repository.dart
···
1
+
import 'package:drift/drift.dart';
2
+
import 'package:lazurite/src/core/utils/logger.dart';
3
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
4
+
import 'package:lazurite/src/infrastructure/db/daos/post_interactions_dao.dart';
5
+
import 'package:lazurite/src/infrastructure/network/xrpc_client.dart';
6
+
7
+
/// Repository for managing post interactions (like, repost, bookmark).
8
+
///
9
+
/// Handles both remote API calls and local state persistence with optimistic updates.
10
+
class PostInteractionRepository {
11
+
PostInteractionRepository(this._api, this._dao, this._logger);
12
+
13
+
final XrpcClient _api;
14
+
final PostInteractionsDao _dao;
15
+
final Logger _logger;
16
+
17
+
/// Likes a post.
18
+
Future<void> like(String postUri, String postCid, String ownerDid) async {
19
+
_logger.info('Liking post', {'uri': postUri, 'owner': ownerDid});
20
+
21
+
final tempUri = 'temp-${DateTime.now().millisecondsSinceEpoch}';
22
+
await _dao.upsertInteraction(
23
+
PostInteractionsCompanion.insert(
24
+
postUri: postUri,
25
+
ownerDid: ownerDid,
26
+
likeUri: Value(tempUri),
27
+
updatedAt: DateTime.now(),
28
+
),
29
+
);
30
+
31
+
try {
32
+
final response = await _api.call(
33
+
'com.atproto.repo.createRecord',
34
+
body: {
35
+
'repo': ownerDid,
36
+
'collection': 'app.bsky.feed.like',
37
+
'record': {
38
+
r'$type': 'app.bsky.feed.like',
39
+
'subject': {'uri': postUri, 'cid': postCid},
40
+
'createdAt': DateTime.now().toUtc().toIso8601String(),
41
+
},
42
+
},
43
+
);
44
+
45
+
final realUri = response['uri'] as String;
46
+
await _dao.upsertInteraction(
47
+
PostInteractionsCompanion.insert(
48
+
postUri: postUri,
49
+
ownerDid: ownerDid,
50
+
likeUri: Value(realUri),
51
+
updatedAt: DateTime.now(),
52
+
),
53
+
);
54
+
} catch (e, stack) {
55
+
_logger.error('Failed to like post', e, stack);
56
+
57
+
await _dao.upsertInteraction(
58
+
PostInteractionsCompanion.insert(
59
+
postUri: postUri,
60
+
ownerDid: ownerDid,
61
+
likeUri: const Value(null),
62
+
updatedAt: DateTime.now(),
63
+
),
64
+
);
65
+
rethrow;
66
+
}
67
+
}
68
+
69
+
/// Unlikes a post.
70
+
Future<void> unlike(String postUri, String likeUri, String ownerDid) async {
71
+
_logger.info('Unliking post', {'uri': postUri, 'likeUri': likeUri});
72
+
73
+
await _dao.upsertInteraction(
74
+
PostInteractionsCompanion.insert(
75
+
postUri: postUri,
76
+
ownerDid: ownerDid,
77
+
likeUri: const Value(null),
78
+
updatedAt: DateTime.now(),
79
+
),
80
+
);
81
+
82
+
try {
83
+
final parts = likeUri.split('/');
84
+
final rkey = parts.last;
85
+
86
+
await _api.call(
87
+
'com.atproto.repo.deleteRecord',
88
+
body: {'repo': ownerDid, 'collection': 'app.bsky.feed.like', 'rkey': rkey},
89
+
);
90
+
} catch (e, stack) {
91
+
_logger.error('Failed to unlike post', e, stack);
92
+
93
+
await _dao.upsertInteraction(
94
+
PostInteractionsCompanion.insert(
95
+
postUri: postUri,
96
+
ownerDid: ownerDid,
97
+
likeUri: Value(likeUri),
98
+
updatedAt: DateTime.now(),
99
+
),
100
+
);
101
+
rethrow;
102
+
}
103
+
}
104
+
105
+
/// Reposts a post.
106
+
Future<void> repost(String postUri, String postCid, String ownerDid) async {
107
+
_logger.info('Reposting post', {'uri': postUri, 'owner': ownerDid});
108
+
109
+
final tempUri = 'temp-${DateTime.now().millisecondsSinceEpoch}';
110
+
await _dao.upsertInteraction(
111
+
PostInteractionsCompanion.insert(
112
+
postUri: postUri,
113
+
ownerDid: ownerDid,
114
+
repostUri: Value(tempUri),
115
+
updatedAt: DateTime.now(),
116
+
),
117
+
);
118
+
119
+
try {
120
+
final response = await _api.call(
121
+
'com.atproto.repo.createRecord',
122
+
body: {
123
+
'repo': ownerDid,
124
+
'collection': 'app.bsky.feed.repost',
125
+
'record': {
126
+
r'$type': 'app.bsky.feed.repost',
127
+
'subject': {'uri': postUri, 'cid': postCid},
128
+
'createdAt': DateTime.now().toUtc().toIso8601String(),
129
+
},
130
+
},
131
+
);
132
+
133
+
final realUri = response['uri'] as String;
134
+
await _dao.upsertInteraction(
135
+
PostInteractionsCompanion.insert(
136
+
postUri: postUri,
137
+
ownerDid: ownerDid,
138
+
repostUri: Value(realUri),
139
+
updatedAt: DateTime.now(),
140
+
),
141
+
);
142
+
} catch (e, stack) {
143
+
_logger.error('Failed to repost post', e, stack);
144
+
145
+
await _dao.upsertInteraction(
146
+
PostInteractionsCompanion.insert(
147
+
postUri: postUri,
148
+
ownerDid: ownerDid,
149
+
repostUri: const Value(null),
150
+
updatedAt: DateTime.now(),
151
+
),
152
+
);
153
+
rethrow;
154
+
}
155
+
}
156
+
157
+
/// Unreposts a post.
158
+
Future<void> unrepost(String postUri, String repostUri, String ownerDid) async {
159
+
_logger.info('Unreposting post', {'uri': postUri, 'repostUri': repostUri});
160
+
161
+
await _dao.upsertInteraction(
162
+
PostInteractionsCompanion.insert(
163
+
postUri: postUri,
164
+
ownerDid: ownerDid,
165
+
repostUri: const Value(null),
166
+
updatedAt: DateTime.now(),
167
+
),
168
+
);
169
+
170
+
try {
171
+
final parts = repostUri.split('/');
172
+
final rkey = parts.last;
173
+
174
+
await _api.call(
175
+
'com.atproto.repo.deleteRecord',
176
+
body: {'repo': ownerDid, 'collection': 'app.bsky.feed.repost', 'rkey': rkey},
177
+
);
178
+
} catch (e, stack) {
179
+
_logger.error('Failed to unrepost post', e, stack);
180
+
181
+
await _dao.upsertInteraction(
182
+
PostInteractionsCompanion.insert(
183
+
postUri: postUri,
184
+
ownerDid: ownerDid,
185
+
repostUri: Value(repostUri),
186
+
updatedAt: DateTime.now(),
187
+
),
188
+
);
189
+
rethrow;
190
+
}
191
+
}
192
+
193
+
/// Bookmarks a post.
194
+
Future<void> bookmark(String postUri, String postCid, String ownerDid) async {
195
+
_logger.info('Bookmarking post', {'uri': postUri});
196
+
197
+
await _dao.upsertInteraction(
198
+
PostInteractionsCompanion.insert(
199
+
postUri: postUri,
200
+
ownerDid: ownerDid,
201
+
bookmarked: const Value(true),
202
+
bookmarkUri: const Value('temp-bookmark'),
203
+
updatedAt: DateTime.now(),
204
+
),
205
+
);
206
+
207
+
try {
208
+
final response = await _api.call(
209
+
'app.bsky.bookmark.createBookmark',
210
+
body: {
211
+
'subject': {'uri': postUri, 'cid': postCid},
212
+
},
213
+
);
214
+
215
+
final uri = response['uri'] as String;
216
+
await _dao.upsertInteraction(
217
+
PostInteractionsCompanion.insert(
218
+
postUri: postUri,
219
+
ownerDid: ownerDid,
220
+
bookmarked: const Value(true),
221
+
bookmarkUri: Value(uri),
222
+
updatedAt: DateTime.now(),
223
+
),
224
+
);
225
+
} catch (e, stack) {
226
+
_logger.error('Failed to bookmark post', e, stack);
227
+
228
+
await _dao.upsertInteraction(
229
+
PostInteractionsCompanion.insert(
230
+
postUri: postUri,
231
+
ownerDid: ownerDid,
232
+
bookmarked: const Value(false),
233
+
bookmarkUri: const Value(null),
234
+
updatedAt: DateTime.now(),
235
+
),
236
+
);
237
+
rethrow;
238
+
}
239
+
}
240
+
241
+
/// Unbookmarks a post.
242
+
Future<void> unbookmark(String postUri, String bookmarkUri, String ownerDid) async {
243
+
_logger.info('Unbookmarking post', {'uri': postUri, 'bookmarkUri': bookmarkUri});
244
+
245
+
await _dao.upsertInteraction(
246
+
PostInteractionsCompanion.insert(
247
+
postUri: postUri,
248
+
ownerDid: ownerDid,
249
+
bookmarked: const Value(false),
250
+
bookmarkUri: const Value(null),
251
+
updatedAt: DateTime.now(),
252
+
),
253
+
);
254
+
255
+
try {
256
+
await _api.call('app.bsky.bookmark.deleteBookmark', body: {'uri': postUri});
257
+
} catch (e, stack) {
258
+
_logger.error('Failed to unbookmark post', e, stack);
259
+
await _dao.upsertInteraction(
260
+
PostInteractionsCompanion.insert(
261
+
postUri: postUri,
262
+
ownerDid: ownerDid,
263
+
bookmarked: const Value(true),
264
+
bookmarkUri: Value(bookmarkUri),
265
+
updatedAt: DateTime.now(),
266
+
),
267
+
);
268
+
rethrow;
269
+
}
270
+
}
271
+
}
+52
-3
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
+52
-3
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
···
4
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
5
import 'package:go_router/go_router.dart';
6
6
import 'package:lazurite/src/core/domain/content_label.dart';
7
+
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
8
+
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
9
+
import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart';
7
10
import 'package:lazurite/src/features/feeds/presentation/widgets/post/content_warning.dart';
8
11
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_actions_row.dart';
9
12
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_body.dart';
···
84
87
replyCount: item.post.replyCount,
85
88
repostCount: item.post.repostCount,
86
89
likeCount: item.post.likeCount,
87
-
viewerLikeUri: item.post.viewerLikeUri,
88
-
viewerRepostUri: item.post.viewerRepostUri,
89
-
viewerBookmarked: item.post.viewerBookmarked,
90
+
viewerLikeUri: item.interaction?.likeUri ?? item.post.viewerLikeUri,
91
+
viewerRepostUri: item.interaction?.repostUri ?? item.post.viewerRepostUri,
92
+
viewerBookmarked: item.interaction?.bookmarked ?? item.post.viewerBookmarked,
93
+
onReply: () {
94
+
final encodedUri = Uri.encodeComponent(item.post.uri);
95
+
GoRouter.of(context).push('/compose?replyTo=$encodedUri');
96
+
},
97
+
onRepost: () async {
98
+
final repo = ref.read(postInteractionRepositoryProvider);
99
+
final auth = ref.read(authProvider);
100
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
101
+
if (ownerDid == null) return;
102
+
103
+
final repostUri = item.interaction?.repostUri ?? item.post.viewerRepostUri;
104
+
if (repostUri != null) {
105
+
await repo.unrepost(item.post.uri, repostUri, ownerDid);
106
+
} else {
107
+
await repo.repost(item.post.uri, item.post.cid, ownerDid);
108
+
}
109
+
},
110
+
onLike: () async {
111
+
final repo = ref.read(postInteractionRepositoryProvider);
112
+
final auth = ref.read(authProvider);
113
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
114
+
if (ownerDid == null) return;
115
+
116
+
final likeUri = item.interaction?.likeUri ?? item.post.viewerLikeUri;
117
+
if (likeUri != null) {
118
+
await repo.unlike(item.post.uri, likeUri, ownerDid);
119
+
} else {
120
+
await repo.like(item.post.uri, item.post.cid, ownerDid);
121
+
}
122
+
},
123
+
onBookmark: () async {
124
+
final repo = ref.read(postInteractionRepositoryProvider);
125
+
final auth = ref.read(authProvider);
126
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
127
+
if (ownerDid == null) return;
128
+
129
+
final bookmarked = item.interaction?.bookmarked ?? item.post.viewerBookmarked;
130
+
if (bookmarked) {
131
+
final bookmarkUri = item.interaction?.bookmarkUri;
132
+
if (bookmarkUri != null) {
133
+
await repo.unbookmark(item.post.uri, bookmarkUri, ownerDid);
134
+
}
135
+
} else {
136
+
await repo.bookmark(item.post.uri, item.post.cid, ownerDid);
137
+
}
138
+
},
90
139
),
91
140
],
92
141
);
+100
-13
lib/src/features/feeds/presentation/widgets/post/post_actions_row.dart
+100
-13
lib/src/features/feeds/presentation/widgets/post/post_actions_row.dart
···
9
9
this.viewerLikeUri,
10
10
this.viewerRepostUri,
11
11
this.viewerBookmarked = false,
12
+
this.onReply,
13
+
this.onRepost,
14
+
this.onLike,
15
+
this.onBookmark,
16
+
this.onMore,
12
17
super.key,
13
18
});
14
19
···
25
30
/// Whether viewer has bookmarked this post.
26
31
final bool viewerBookmarked;
27
32
33
+
final VoidCallback? onReply;
34
+
final VoidCallback? onRepost;
35
+
final VoidCallback? onLike;
36
+
final VoidCallback? onBookmark;
37
+
final VoidCallback? onMore;
38
+
28
39
@override
29
40
Widget build(BuildContext context) {
30
41
final isLiked = viewerLikeUri != null;
···
33
44
return Row(
34
45
mainAxisAlignment: MainAxisAlignment.spaceBetween,
35
46
children: [
36
-
_ActionItem(icon: Icons.chat_bubble_outline, count: replyCount),
47
+
_ActionItem(
48
+
icon: Icons.chat_bubble_outline,
49
+
count: replyCount,
50
+
onTap: onReply,
51
+
tooltip: 'Reply',
52
+
),
37
53
_ActionItem(
38
54
icon: Icons.repeat,
39
55
count: repostCount,
40
56
isActive: isReposted,
41
57
activeColor: Colors.green,
58
+
onTap: onRepost,
59
+
tooltip: isReposted ? 'Unrepost' : 'Repost',
42
60
),
43
61
_ActionItem(
44
62
icon: isLiked ? Icons.favorite : Icons.favorite_border,
45
63
count: likeCount,
46
64
isActive: isLiked,
47
65
activeColor: Colors.red,
66
+
onTap: onLike,
67
+
tooltip: isLiked ? 'Unlike' : 'Like',
48
68
),
49
69
_ActionItem(
50
70
icon: viewerBookmarked ? Icons.bookmark : Icons.bookmark_border,
51
71
count: 0,
52
72
isActive: viewerBookmarked,
53
73
activeColor: Colors.amber,
74
+
onTap: onBookmark,
75
+
tooltip: viewerBookmarked ? 'Remove Bookmark' : 'Bookmark',
54
76
),
55
-
const Icon(Icons.more_horiz, size: 18, color: AppColors.textSecondary),
77
+
IconButton(
78
+
onPressed: onMore,
79
+
icon: const Icon(Icons.more_horiz, size: 18),
80
+
color: AppColors.textSecondary,
81
+
tooltip: 'More',
82
+
padding: EdgeInsets.zero,
83
+
constraints: const BoxConstraints(),
84
+
),
56
85
],
57
86
);
58
87
}
59
88
}
60
89
61
-
class _ActionItem extends StatelessWidget {
90
+
class _ActionItem extends StatefulWidget {
62
91
const _ActionItem({
63
92
required this.icon,
64
-
required this.count,
93
+
this.count = 0,
65
94
this.isActive = false,
66
95
this.activeColor,
96
+
this.onTap,
97
+
this.tooltip,
67
98
});
68
99
69
100
final IconData icon;
70
101
final int count;
71
102
final bool isActive;
72
103
final Color? activeColor;
104
+
final VoidCallback? onTap;
105
+
final String? tooltip;
106
+
107
+
@override
108
+
State<_ActionItem> createState() => _ActionItemState();
109
+
}
110
+
111
+
class _ActionItemState extends State<_ActionItem> with SingleTickerProviderStateMixin {
112
+
late AnimationController _controller;
113
+
late Animation<double> _scaleAnimation;
114
+
115
+
@override
116
+
void initState() {
117
+
super.initState();
118
+
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 100));
119
+
_scaleAnimation = Tween<double>(
120
+
begin: 1.0,
121
+
end: 1.2,
122
+
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
123
+
}
124
+
125
+
@override
126
+
void dispose() {
127
+
_controller.dispose();
128
+
super.dispose();
129
+
}
130
+
131
+
void _handleTap() {
132
+
if (widget.onTap == null) return;
133
+
134
+
_controller.forward().then((_) => _controller.reverse());
135
+
widget.onTap!();
136
+
}
73
137
74
138
@override
75
139
Widget build(BuildContext context) {
76
-
final color = isActive ? activeColor ?? AppColors.textSecondary : AppColors.textSecondary;
140
+
final color = widget.isActive
141
+
? widget.activeColor ?? AppColors.textSecondary
142
+
: AppColors.textSecondary;
77
143
78
-
return Row(
79
-
children: [
80
-
Icon(icon, size: 18, color: color),
81
-
if (count > 0) ...[
82
-
const SizedBox(width: 4),
83
-
Text(_formatCount(count), style: TextStyle(color: color, fontSize: 13)),
84
-
],
85
-
],
144
+
return Tooltip(
145
+
message: widget.tooltip ?? '',
146
+
child: InkWell(
147
+
onTap: _handleTap,
148
+
borderRadius: BorderRadius.circular(20),
149
+
child: Padding(
150
+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
151
+
child: Row(
152
+
mainAxisSize: MainAxisSize.min,
153
+
children: [
154
+
ScaleTransition(
155
+
scale: _scaleAnimation,
156
+
child: Icon(widget.icon, size: 18, color: color),
157
+
),
158
+
if (widget.count > 0) ...[
159
+
const SizedBox(width: 4),
160
+
Text(
161
+
_formatCount(widget.count),
162
+
style: TextStyle(
163
+
color: color,
164
+
fontSize: 13,
165
+
fontWeight: widget.isActive ? FontWeight.bold : FontWeight.normal,
166
+
),
167
+
),
168
+
],
169
+
],
170
+
),
171
+
),
172
+
),
86
173
);
87
174
}
88
175
+1
-1
lib/src/features/notifications/application/notifications_notifier.g.dart
+1
-1
lib/src/features/notifications/application/notifications_notifier.g.dart
···
48
48
NotificationsNotifier create() => NotificationsNotifier();
49
49
}
50
50
51
-
String _$notificationsNotifierHash() => r'650ebb9034f46b19f7440fa0b1ceb5aff49d4431';
51
+
String _$notificationsNotifierHash() => r'67a09107637de31ef8a7e59d2f9c1d9d34487e74';
52
52
53
53
/// Notifier for managing notification list state.
54
54
///
+54
-3
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
+54
-3
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:go_router/go_router.dart';
4
+
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
5
+
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
6
+
import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart';
3
7
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_actions_row.dart';
4
8
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_body.dart';
5
9
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_embeds.dart';
···
16
20
Widget build(BuildContext context, WidgetRef ref) {
17
21
final theme = Theme.of(context);
18
22
final postAsync = ref.watch(pinnedPostProvider(postUri));
23
+
final interaction = ref.watch(postInteractionStateProvider(postUri)).value;
19
24
20
25
return postAsync.when(
21
26
data: (item) {
···
63
68
replyCount: item.replyCount,
64
69
repostCount: item.repostCount,
65
70
likeCount: item.likeCount,
66
-
viewerLikeUri: item.viewerLikeUri,
67
-
viewerRepostUri: item.viewerRepostUri,
68
-
viewerBookmarked: item.viewerBookmarked,
71
+
viewerLikeUri: interaction?.likeUri ?? item.viewerLikeUri,
72
+
viewerRepostUri: interaction?.repostUri ?? item.viewerRepostUri,
73
+
viewerBookmarked: interaction?.bookmarked ?? item.viewerBookmarked,
74
+
onReply: () {
75
+
final encodedUri = Uri.encodeComponent(postUri);
76
+
GoRouter.of(context).push('/compose?replyTo=$encodedUri');
77
+
},
78
+
onRepost: () async {
79
+
final repo = ref.read(postInteractionRepositoryProvider);
80
+
final auth = ref.read(authProvider);
81
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
82
+
if (ownerDid == null) return;
83
+
84
+
final repostUri = interaction?.repostUri ?? item.viewerRepostUri;
85
+
if (repostUri != null) {
86
+
await repo.unrepost(postUri, repostUri, ownerDid);
87
+
} else {
88
+
await repo.repost(postUri, item.cid, ownerDid);
89
+
}
90
+
},
91
+
onLike: () async {
92
+
final repo = ref.read(postInteractionRepositoryProvider);
93
+
final auth = ref.read(authProvider);
94
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
95
+
if (ownerDid == null) return;
96
+
97
+
final likeUri = interaction?.likeUri ?? item.viewerLikeUri;
98
+
if (likeUri != null) {
99
+
await repo.unlike(postUri, likeUri, ownerDid);
100
+
} else {
101
+
await repo.like(postUri, item.cid, ownerDid);
102
+
}
103
+
},
104
+
onBookmark: () async {
105
+
final repo = ref.read(postInteractionRepositoryProvider);
106
+
final auth = ref.read(authProvider);
107
+
final ownerDid = (auth is AuthStateAuthenticated) ? auth.session.did : null;
108
+
if (ownerDid == null) return;
109
+
110
+
final bookmarked = interaction?.bookmarked ?? item.viewerBookmarked;
111
+
if (bookmarked) {
112
+
final bookmarkUri = interaction?.bookmarkUri;
113
+
if (bookmarkUri != null) {
114
+
await repo.unbookmark(postUri, bookmarkUri, ownerDid);
115
+
}
116
+
} else {
117
+
await repo.bookmark(postUri, item.cid, ownerDid);
118
+
}
119
+
},
69
120
),
70
121
],
71
122
),
+71
-2
lib/src/infrastructure/db/app_database.g.dart
+71
-2
lib/src/infrastructure/db/app_database.g.dart
···
7054
7054
type: DriftSqlType.string,
7055
7055
requiredDuringInsert: false,
7056
7056
);
7057
+
static const VerificationMeta _bookmarkUriMeta = const VerificationMeta('bookmarkUri');
7058
+
@override
7059
+
late final GeneratedColumn<String> bookmarkUri = GeneratedColumn<String>(
7060
+
'bookmark_uri',
7061
+
aliasedName,
7062
+
true,
7063
+
type: DriftSqlType.string,
7064
+
requiredDuringInsert: false,
7065
+
);
7057
7066
static const VerificationMeta _bookmarkedMeta = const VerificationMeta('bookmarked');
7058
7067
@override
7059
7068
late final GeneratedColumn<bool> bookmarked = GeneratedColumn<bool>(
···
7091
7100
ownerDid,
7092
7101
likeUri,
7093
7102
repostUri,
7103
+
bookmarkUri,
7094
7104
bookmarked,
7095
7105
threadMuted,
7096
7106
updatedAt,
···
7129
7139
repostUri.isAcceptableOrUnknown(data['repost_uri']!, _repostUriMeta),
7130
7140
);
7131
7141
}
7142
+
if (data.containsKey('bookmark_uri')) {
7143
+
context.handle(
7144
+
_bookmarkUriMeta,
7145
+
bookmarkUri.isAcceptableOrUnknown(data['bookmark_uri']!, _bookmarkUriMeta),
7146
+
);
7147
+
}
7132
7148
if (data.containsKey('bookmarked')) {
7133
7149
context.handle(
7134
7150
_bookmarkedMeta,
···
7174
7190
DriftSqlType.string,
7175
7191
data['${effectivePrefix}repost_uri'],
7176
7192
),
7193
+
bookmarkUri: attachedDatabase.typeMapping.read(
7194
+
DriftSqlType.string,
7195
+
data['${effectivePrefix}bookmark_uri'],
7196
+
),
7177
7197
bookmarked: attachedDatabase.typeMapping.read(
7178
7198
DriftSqlType.bool,
7179
7199
data['${effectivePrefix}bookmarked'],
···
7208
7228
/// AT URI of the repost record (if reposted).
7209
7229
final String? repostUri;
7210
7230
7231
+
/// AT URI of the bookmark record (if bookmarked).
7232
+
final String? bookmarkUri;
7233
+
7211
7234
/// Whether the post is bookmarked.
7212
7235
final bool bookmarked;
7213
7236
···
7221
7244
required this.ownerDid,
7222
7245
this.likeUri,
7223
7246
this.repostUri,
7247
+
this.bookmarkUri,
7224
7248
required this.bookmarked,
7225
7249
required this.threadMuted,
7226
7250
required this.updatedAt,
···
7236
7260
if (!nullToAbsent || repostUri != null) {
7237
7261
map['repost_uri'] = Variable<String>(repostUri);
7238
7262
}
7263
+
if (!nullToAbsent || bookmarkUri != null) {
7264
+
map['bookmark_uri'] = Variable<String>(bookmarkUri);
7265
+
}
7239
7266
map['bookmarked'] = Variable<bool>(bookmarked);
7240
7267
map['thread_muted'] = Variable<bool>(threadMuted);
7241
7268
map['updated_at'] = Variable<DateTime>(updatedAt);
···
7248
7275
ownerDid: Value(ownerDid),
7249
7276
likeUri: likeUri == null && nullToAbsent ? const Value.absent() : Value(likeUri),
7250
7277
repostUri: repostUri == null && nullToAbsent ? const Value.absent() : Value(repostUri),
7278
+
bookmarkUri: bookmarkUri == null && nullToAbsent ? const Value.absent() : Value(bookmarkUri),
7251
7279
bookmarked: Value(bookmarked),
7252
7280
threadMuted: Value(threadMuted),
7253
7281
updatedAt: Value(updatedAt),
···
7261
7289
ownerDid: serializer.fromJson<String>(json['ownerDid']),
7262
7290
likeUri: serializer.fromJson<String?>(json['likeUri']),
7263
7291
repostUri: serializer.fromJson<String?>(json['repostUri']),
7292
+
bookmarkUri: serializer.fromJson<String?>(json['bookmarkUri']),
7264
7293
bookmarked: serializer.fromJson<bool>(json['bookmarked']),
7265
7294
threadMuted: serializer.fromJson<bool>(json['threadMuted']),
7266
7295
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
···
7274
7303
'ownerDid': serializer.toJson<String>(ownerDid),
7275
7304
'likeUri': serializer.toJson<String?>(likeUri),
7276
7305
'repostUri': serializer.toJson<String?>(repostUri),
7306
+
'bookmarkUri': serializer.toJson<String?>(bookmarkUri),
7277
7307
'bookmarked': serializer.toJson<bool>(bookmarked),
7278
7308
'threadMuted': serializer.toJson<bool>(threadMuted),
7279
7309
'updatedAt': serializer.toJson<DateTime>(updatedAt),
···
7285
7315
String? ownerDid,
7286
7316
Value<String?> likeUri = const Value.absent(),
7287
7317
Value<String?> repostUri = const Value.absent(),
7318
+
Value<String?> bookmarkUri = const Value.absent(),
7288
7319
bool? bookmarked,
7289
7320
bool? threadMuted,
7290
7321
DateTime? updatedAt,
···
7293
7324
ownerDid: ownerDid ?? this.ownerDid,
7294
7325
likeUri: likeUri.present ? likeUri.value : this.likeUri,
7295
7326
repostUri: repostUri.present ? repostUri.value : this.repostUri,
7327
+
bookmarkUri: bookmarkUri.present ? bookmarkUri.value : this.bookmarkUri,
7296
7328
bookmarked: bookmarked ?? this.bookmarked,
7297
7329
threadMuted: threadMuted ?? this.threadMuted,
7298
7330
updatedAt: updatedAt ?? this.updatedAt,
···
7303
7335
ownerDid: data.ownerDid.present ? data.ownerDid.value : this.ownerDid,
7304
7336
likeUri: data.likeUri.present ? data.likeUri.value : this.likeUri,
7305
7337
repostUri: data.repostUri.present ? data.repostUri.value : this.repostUri,
7338
+
bookmarkUri: data.bookmarkUri.present ? data.bookmarkUri.value : this.bookmarkUri,
7306
7339
bookmarked: data.bookmarked.present ? data.bookmarked.value : this.bookmarked,
7307
7340
threadMuted: data.threadMuted.present ? data.threadMuted.value : this.threadMuted,
7308
7341
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
···
7316
7349
..write('ownerDid: $ownerDid, ')
7317
7350
..write('likeUri: $likeUri, ')
7318
7351
..write('repostUri: $repostUri, ')
7352
+
..write('bookmarkUri: $bookmarkUri, ')
7319
7353
..write('bookmarked: $bookmarked, ')
7320
7354
..write('threadMuted: $threadMuted, ')
7321
7355
..write('updatedAt: $updatedAt')
···
7324
7358
}
7325
7359
7326
7360
@override
7327
-
int get hashCode =>
7328
-
Object.hash(postUri, ownerDid, likeUri, repostUri, bookmarked, threadMuted, updatedAt);
7361
+
int get hashCode => Object.hash(
7362
+
postUri,
7363
+
ownerDid,
7364
+
likeUri,
7365
+
repostUri,
7366
+
bookmarkUri,
7367
+
bookmarked,
7368
+
threadMuted,
7369
+
updatedAt,
7370
+
);
7329
7371
@override
7330
7372
bool operator ==(Object other) =>
7331
7373
identical(this, other) ||
···
7334
7376
other.ownerDid == this.ownerDid &&
7335
7377
other.likeUri == this.likeUri &&
7336
7378
other.repostUri == this.repostUri &&
7379
+
other.bookmarkUri == this.bookmarkUri &&
7337
7380
other.bookmarked == this.bookmarked &&
7338
7381
other.threadMuted == this.threadMuted &&
7339
7382
other.updatedAt == this.updatedAt);
···
7344
7387
final Value<String> ownerDid;
7345
7388
final Value<String?> likeUri;
7346
7389
final Value<String?> repostUri;
7390
+
final Value<String?> bookmarkUri;
7347
7391
final Value<bool> bookmarked;
7348
7392
final Value<bool> threadMuted;
7349
7393
final Value<DateTime> updatedAt;
···
7353
7397
this.ownerDid = const Value.absent(),
7354
7398
this.likeUri = const Value.absent(),
7355
7399
this.repostUri = const Value.absent(),
7400
+
this.bookmarkUri = const Value.absent(),
7356
7401
this.bookmarked = const Value.absent(),
7357
7402
this.threadMuted = const Value.absent(),
7358
7403
this.updatedAt = const Value.absent(),
···
7363
7408
required String ownerDid,
7364
7409
this.likeUri = const Value.absent(),
7365
7410
this.repostUri = const Value.absent(),
7411
+
this.bookmarkUri = const Value.absent(),
7366
7412
this.bookmarked = const Value.absent(),
7367
7413
this.threadMuted = const Value.absent(),
7368
7414
required DateTime updatedAt,
···
7375
7421
Expression<String>? ownerDid,
7376
7422
Expression<String>? likeUri,
7377
7423
Expression<String>? repostUri,
7424
+
Expression<String>? bookmarkUri,
7378
7425
Expression<bool>? bookmarked,
7379
7426
Expression<bool>? threadMuted,
7380
7427
Expression<DateTime>? updatedAt,
···
7385
7432
if (ownerDid != null) 'owner_did': ownerDid,
7386
7433
if (likeUri != null) 'like_uri': likeUri,
7387
7434
if (repostUri != null) 'repost_uri': repostUri,
7435
+
if (bookmarkUri != null) 'bookmark_uri': bookmarkUri,
7388
7436
if (bookmarked != null) 'bookmarked': bookmarked,
7389
7437
if (threadMuted != null) 'thread_muted': threadMuted,
7390
7438
if (updatedAt != null) 'updated_at': updatedAt,
···
7397
7445
Value<String>? ownerDid,
7398
7446
Value<String?>? likeUri,
7399
7447
Value<String?>? repostUri,
7448
+
Value<String?>? bookmarkUri,
7400
7449
Value<bool>? bookmarked,
7401
7450
Value<bool>? threadMuted,
7402
7451
Value<DateTime>? updatedAt,
···
7407
7456
ownerDid: ownerDid ?? this.ownerDid,
7408
7457
likeUri: likeUri ?? this.likeUri,
7409
7458
repostUri: repostUri ?? this.repostUri,
7459
+
bookmarkUri: bookmarkUri ?? this.bookmarkUri,
7410
7460
bookmarked: bookmarked ?? this.bookmarked,
7411
7461
threadMuted: threadMuted ?? this.threadMuted,
7412
7462
updatedAt: updatedAt ?? this.updatedAt,
···
7429
7479
if (repostUri.present) {
7430
7480
map['repost_uri'] = Variable<String>(repostUri.value);
7431
7481
}
7482
+
if (bookmarkUri.present) {
7483
+
map['bookmark_uri'] = Variable<String>(bookmarkUri.value);
7484
+
}
7432
7485
if (bookmarked.present) {
7433
7486
map['bookmarked'] = Variable<bool>(bookmarked.value);
7434
7487
}
···
7451
7504
..write('ownerDid: $ownerDid, ')
7452
7505
..write('likeUri: $likeUri, ')
7453
7506
..write('repostUri: $repostUri, ')
7507
+
..write('bookmarkUri: $bookmarkUri, ')
7454
7508
..write('bookmarked: $bookmarked, ')
7455
7509
..write('threadMuted: $threadMuted, ')
7456
7510
..write('updatedAt: $updatedAt, ')
···
17580
17634
required String ownerDid,
17581
17635
Value<String?> likeUri,
17582
17636
Value<String?> repostUri,
17637
+
Value<String?> bookmarkUri,
17583
17638
Value<bool> bookmarked,
17584
17639
Value<bool> threadMuted,
17585
17640
required DateTime updatedAt,
···
17591
17646
Value<String> ownerDid,
17592
17647
Value<String?> likeUri,
17593
17648
Value<String?> repostUri,
17649
+
Value<String?> bookmarkUri,
17594
17650
Value<bool> bookmarked,
17595
17651
Value<bool> threadMuted,
17596
17652
Value<DateTime> updatedAt,
···
17635
17691
ColumnFilters<String> get repostUri =>
17636
17692
$composableBuilder(column: $table.repostUri, builder: (column) => ColumnFilters(column));
17637
17693
17694
+
ColumnFilters<String> get bookmarkUri =>
17695
+
$composableBuilder(column: $table.bookmarkUri, builder: (column) => ColumnFilters(column));
17696
+
17638
17697
ColumnFilters<bool> get bookmarked =>
17639
17698
$composableBuilder(column: $table.bookmarked, builder: (column) => ColumnFilters(column));
17640
17699
···
17681
17740
17682
17741
ColumnOrderings<String> get repostUri =>
17683
17742
$composableBuilder(column: $table.repostUri, builder: (column) => ColumnOrderings(column));
17743
+
17744
+
ColumnOrderings<String> get bookmarkUri =>
17745
+
$composableBuilder(column: $table.bookmarkUri, builder: (column) => ColumnOrderings(column));
17684
17746
17685
17747
ColumnOrderings<bool> get bookmarked =>
17686
17748
$composableBuilder(column: $table.bookmarked, builder: (column) => ColumnOrderings(column));
···
17729
17791
GeneratedColumn<String> get repostUri =>
17730
17792
$composableBuilder(column: $table.repostUri, builder: (column) => column);
17731
17793
17794
+
GeneratedColumn<String> get bookmarkUri =>
17795
+
$composableBuilder(column: $table.bookmarkUri, builder: (column) => column);
17796
+
17732
17797
GeneratedColumn<bool> get bookmarked =>
17733
17798
$composableBuilder(column: $table.bookmarked, builder: (column) => column);
17734
17799
···
17790
17855
Value<String> ownerDid = const Value.absent(),
17791
17856
Value<String?> likeUri = const Value.absent(),
17792
17857
Value<String?> repostUri = const Value.absent(),
17858
+
Value<String?> bookmarkUri = const Value.absent(),
17793
17859
Value<bool> bookmarked = const Value.absent(),
17794
17860
Value<bool> threadMuted = const Value.absent(),
17795
17861
Value<DateTime> updatedAt = const Value.absent(),
···
17799
17865
ownerDid: ownerDid,
17800
17866
likeUri: likeUri,
17801
17867
repostUri: repostUri,
17868
+
bookmarkUri: bookmarkUri,
17802
17869
bookmarked: bookmarked,
17803
17870
threadMuted: threadMuted,
17804
17871
updatedAt: updatedAt,
···
17810
17877
required String ownerDid,
17811
17878
Value<String?> likeUri = const Value.absent(),
17812
17879
Value<String?> repostUri = const Value.absent(),
17880
+
Value<String?> bookmarkUri = const Value.absent(),
17813
17881
Value<bool> bookmarked = const Value.absent(),
17814
17882
Value<bool> threadMuted = const Value.absent(),
17815
17883
required DateTime updatedAt,
···
17819
17887
ownerDid: ownerDid,
17820
17888
likeUri: likeUri,
17821
17889
repostUri: repostUri,
17890
+
bookmarkUri: bookmarkUri,
17822
17891
bookmarked: bookmarked,
17823
17892
threadMuted: threadMuted,
17824
17893
updatedAt: updatedAt,
+17
-2
lib/src/infrastructure/db/daos/feed_content_dao.dart
+17
-2
lib/src/infrastructure/db/daos/feed_content_dao.dart
···
10
10
/// This replaces TimelineDao with clearer naming that aligns with BlueSky's
11
11
/// feed-based architecture. Uses the same underlying tables (Posts, Profiles,
12
12
/// FeedContentItems, FeedCursors).
13
-
@DriftAccessor(tables: [Posts, Profiles, ProfileRelationships, FeedContentItems, FeedCursors])
13
+
@DriftAccessor(
14
+
tables: [Posts, Profiles, ProfileRelationships, PostInteractions, FeedContentItems, FeedCursors],
15
+
)
14
16
class FeedContentDao extends DatabaseAccessor<AppDatabase> with _$FeedContentDaoMixin {
15
17
FeedContentDao(super.db);
16
18
···
64
66
innerJoin(posts, posts.uri.equalsExp(feedContentItems.postUri)),
65
67
innerJoin(profiles, profiles.did.equalsExp(posts.authorDid)),
66
68
leftOuterJoin(profileRelationships, profileRelationships.profileDid.equalsExp(profiles.did)),
69
+
leftOuterJoin(
70
+
postInteractions,
71
+
postInteractions.postUri.equalsExp(posts.uri) &
72
+
postInteractions.ownerDid.equalsExp(feedContentItems.ownerDid),
73
+
),
67
74
]);
68
75
69
76
query.where(
···
78
85
post: row.readTable(posts),
79
86
author: row.readTable(profiles),
80
87
relationship: row.readTableOrNull(profileRelationships),
88
+
interaction: row.readTableOrNull(postInteractions),
81
89
reason: feedItem.reason,
82
90
);
83
91
}).toList();
···
131
139
///
132
140
/// Combines post content, author profile, and feed-specific data like repost reason.
133
141
class FeedPost {
134
-
FeedPost({required this.post, required this.author, this.relationship, this.reason});
142
+
FeedPost({
143
+
required this.post,
144
+
required this.author,
145
+
this.relationship,
146
+
this.interaction,
147
+
this.reason,
148
+
});
135
149
136
150
final Post post;
137
151
final Profile author;
138
152
final ProfileRelationship? relationship;
153
+
final PostInteraction? interaction;
139
154
140
155
/// Feed-specific reason (e.g., repost information as JSON).
141
156
final String? reason;
+1
lib/src/infrastructure/db/daos/feed_content_dao.g.dart
+1
lib/src/infrastructure/db/daos/feed_content_dao.g.dart
···
7
7
$ProfilesTable get profiles => attachedDatabase.profiles;
8
8
$PostsTable get posts => attachedDatabase.posts;
9
9
$ProfileRelationshipsTable get profileRelationships => attachedDatabase.profileRelationships;
10
+
$PostInteractionsTable get postInteractions => attachedDatabase.postInteractions;
10
11
$FeedContentItemsTable get feedContentItems => attachedDatabase.feedContentItems;
11
12
$FeedCursorsTable get feedCursors => attachedDatabase.feedCursors;
12
13
}
+3
lib/src/infrastructure/db/tables.dart
+3
lib/src/infrastructure/db/tables.dart
···
44
44
/// AT URI of the repost record (if reposted).
45
45
TextColumn get repostUri => text().nullable()();
46
46
47
+
/// AT URI of the bookmark record (if bookmarked).
48
+
TextColumn get bookmarkUri => text().nullable()();
49
+
47
50
/// Whether the post is bookmarked.
48
51
BoolColumn get bookmarked => boolean().withDefault(const Constant(false))();
49
52
+18
lib/src/infrastructure/network/endpoint_registry.dart
+18
lib/src/infrastructure/network/endpoint_registry.dart
···
167
167
hostKind: HostKind.pds,
168
168
requiresAuth: true,
169
169
),
170
+
'app.bsky.bookmark.createBookmark': const EndpointMeta(
171
+
nsid: 'app.bsky.bookmark.createBookmark',
172
+
method: HttpMethod.post,
173
+
hostKind: HostKind.pds,
174
+
requiresAuth: true,
175
+
),
176
+
'app.bsky.bookmark.deleteBookmark': const EndpointMeta(
177
+
nsid: 'app.bsky.bookmark.deleteBookmark',
178
+
method: HttpMethod.post,
179
+
hostKind: HostKind.pds,
180
+
requiresAuth: true,
181
+
),
182
+
'app.bsky.bookmark.getBookmarks': const EndpointMeta(
183
+
nsid: 'app.bsky.bookmark.getBookmarks',
184
+
method: HttpMethod.get,
185
+
hostKind: HostKind.pds,
186
+
requiresAuth: true,
187
+
),
170
188
171
189
'com.atproto.server.createSession': const EndpointMeta(
172
190
nsid: 'com.atproto.server.createSession',
+1
-1
lib/src/infrastructure/network/providers.g.dart
+1
-1
lib/src/infrastructure/network/providers.g.dart
+146
test/src/features/feeds/infrastructure/post_interaction_repository_test.dart
+146
test/src/features/feeds/infrastructure/post_interaction_repository_test.dart
···
1
+
import 'package:flutter_test/flutter_test.dart';
2
+
import 'package:lazurite/src/core/utils/logger.dart';
3
+
import 'package:lazurite/src/features/feeds/infrastructure/post_interaction_repository.dart';
4
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
5
+
import 'package:lazurite/src/infrastructure/db/daos/post_interactions_dao.dart';
6
+
import 'package:lazurite/src/infrastructure/network/xrpc_client.dart';
7
+
import 'package:mocktail/mocktail.dart';
8
+
9
+
class MockXrpcClient extends Mock implements XrpcClient {}
10
+
11
+
class MockPostInteractionsDao extends Mock implements PostInteractionsDao {}
12
+
13
+
class MockLogger extends Mock implements Logger {}
14
+
15
+
void main() {
16
+
late MockXrpcClient api;
17
+
late MockPostInteractionsDao dao;
18
+
late MockLogger logger;
19
+
late PostInteractionRepository repository;
20
+
21
+
const ownerDid = 'did:plc:owner';
22
+
const postUri = 'at://did:plc:author/app.bsky.feed.post/123';
23
+
const postCid = 'bafyreih57axcs6fyz2p2y27i3m2j2';
24
+
25
+
setUpAll(() {
26
+
registerFallbackValue(
27
+
PostInteractionsCompanion.insert(
28
+
postUri: 'dummy',
29
+
ownerDid: 'dummy',
30
+
updatedAt: DateTime.now(),
31
+
),
32
+
);
33
+
});
34
+
35
+
setUp(() {
36
+
api = MockXrpcClient();
37
+
dao = MockPostInteractionsDao();
38
+
logger = MockLogger();
39
+
repository = PostInteractionRepository(api, dao, logger);
40
+
41
+
when(() => logger.info(any(), any())).thenReturn(null);
42
+
when(() => logger.error(any(), any(), any())).thenReturn(null);
43
+
});
44
+
45
+
group('PostInteractionRepository', () {
46
+
test('like optimistic update and API call success', () async {
47
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
48
+
when(
49
+
() => api.call('com.atproto.repo.createRecord', body: any(named: 'body')),
50
+
).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.feed.like/456'});
51
+
52
+
await repository.like(postUri, postCid, ownerDid);
53
+
54
+
verify(() => dao.upsertInteraction(any())).called(2);
55
+
verify(() => api.call('com.atproto.repo.createRecord', body: any(named: 'body'))).called(1);
56
+
});
57
+
58
+
test('like rollback on API failure', () async {
59
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
60
+
when(
61
+
() => api.call('com.atproto.repo.createRecord', body: any(named: 'body')),
62
+
).thenThrow(Exception('API Error'));
63
+
64
+
try {
65
+
await repository.like(postUri, postCid, ownerDid);
66
+
fail('Should have thrown');
67
+
} catch (_) {
68
+
/* Expected */
69
+
}
70
+
71
+
verify(() => dao.upsertInteraction(any())).called(2);
72
+
});
73
+
74
+
test('repost rollback on API failure', () async {
75
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
76
+
when(
77
+
() => api.call('com.atproto.repo.createRecord', body: any(named: 'body')),
78
+
).thenThrow(Exception('API Error'));
79
+
80
+
try {
81
+
await repository.repost(postUri, postCid, ownerDid);
82
+
fail('Should have thrown');
83
+
} catch (_) {
84
+
/* Expected */
85
+
}
86
+
87
+
verify(() => dao.upsertInteraction(any())).called(2);
88
+
});
89
+
90
+
test('bookmark rollback on API failure', () async {
91
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
92
+
when(
93
+
() => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')),
94
+
).thenThrow(Exception('API Error'));
95
+
96
+
try {
97
+
await repository.bookmark(postUri, postCid, ownerDid);
98
+
fail('Should have thrown');
99
+
} catch (_) {
100
+
/* Expected */
101
+
}
102
+
103
+
verify(() => dao.upsertInteraction(any())).called(2);
104
+
});
105
+
106
+
test('repost optimistic update and API call success', () async {
107
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
108
+
when(
109
+
() => api.call('com.atproto.repo.createRecord', body: any(named: 'body')),
110
+
).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.feed.repost/789'});
111
+
112
+
await repository.repost(postUri, postCid, ownerDid);
113
+
114
+
verify(() => dao.upsertInteraction(any())).called(2);
115
+
verify(() => api.call('com.atproto.repo.createRecord', body: any(named: 'body'))).called(1);
116
+
});
117
+
118
+
test('bookmark optimistic update and API call success', () async {
119
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
120
+
when(
121
+
() => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')),
122
+
).thenAnswer((_) async => {'uri': 'at://did:plc:owner/app.bsky.bookmark/abc'});
123
+
124
+
await repository.bookmark(postUri, postCid, ownerDid);
125
+
126
+
verify(() => dao.upsertInteraction(any())).called(2);
127
+
verify(
128
+
() => api.call('app.bsky.bookmark.createBookmark', body: any(named: 'body')),
129
+
).called(1);
130
+
});
131
+
132
+
test('unbookmark optimistic update and API call success', () async {
133
+
when(() => dao.upsertInteraction(any())).thenAnswer((_) async => {});
134
+
when(
135
+
() => api.call('app.bsky.bookmark.deleteBookmark', body: any(named: 'body')),
136
+
).thenAnswer((_) async => {});
137
+
138
+
await repository.unbookmark(postUri, 'at://did:plc:owner/app.bsky.bookmark/abc', ownerDid);
139
+
140
+
verify(() => dao.upsertInteraction(any())).called(1);
141
+
verify(
142
+
() => api.call('app.bsky.bookmark.deleteBookmark', body: any(named: 'body')),
143
+
).called(1);
144
+
});
145
+
});
146
+
}
+69
test/src/features/feeds/presentation/widgets/post/post_actions_row_test.dart
+69
test/src/features/feeds/presentation/widgets/post/post_actions_row_test.dart
···
33
33
expect(find.text('0'), findsNothing);
34
34
});
35
35
36
+
testWidgets('renders all action counts correctly', (tester) async {
37
+
await tester.pumpWidget(
38
+
const MaterialApp(
39
+
home: Scaffold(body: PostActionsRow(replyCount: 10, repostCount: 20, likeCount: 30)),
40
+
),
41
+
);
42
+
43
+
expect(find.text('10'), findsOneWidget);
44
+
expect(find.text('20'), findsOneWidget);
45
+
expect(find.text('30'), findsOneWidget);
46
+
});
47
+
48
+
testWidgets('applies active color when interacted', (tester) async {
49
+
await tester.pumpWidget(
50
+
const MaterialApp(
51
+
home: Scaffold(
52
+
body: PostActionsRow(
53
+
viewerLikeUri: 'some-uri',
54
+
viewerRepostUri: 'some-uri',
55
+
viewerBookmarked: true,
56
+
),
57
+
),
58
+
),
59
+
);
60
+
61
+
final likeIcon = find.byIcon(Icons.favorite);
62
+
final repostIcon = find.byIcon(Icons.repeat);
63
+
final bookmarkIcon = find.byIcon(Icons.bookmark);
64
+
65
+
expect(likeIcon, findsOneWidget);
66
+
expect(repostIcon, findsOneWidget);
67
+
expect(bookmarkIcon, findsOneWidget);
68
+
69
+
final likeWidget = tester.widget<Icon>(likeIcon);
70
+
expect(likeWidget.color, Colors.red);
71
+
});
72
+
73
+
testWidgets('calls callbacks when items are tapped', (tester) async {
74
+
bool likeCalled = false;
75
+
bool repostCalled = false;
76
+
bool bookmarkCalled = false;
77
+
bool replyCalled = false;
78
+
79
+
await tester.pumpWidget(
80
+
MaterialApp(
81
+
home: Scaffold(
82
+
body: PostActionsRow(
83
+
onLike: () => likeCalled = true,
84
+
onRepost: () => repostCalled = true,
85
+
onBookmark: () => bookmarkCalled = true,
86
+
onReply: () => replyCalled = true,
87
+
),
88
+
),
89
+
),
90
+
);
91
+
92
+
await tester.tap(find.byTooltip('Like'));
93
+
expect(likeCalled, isTrue);
94
+
95
+
await tester.tap(find.byTooltip('Repost'));
96
+
expect(repostCalled, isTrue);
97
+
98
+
await tester.tap(find.byTooltip('Bookmark'));
99
+
expect(bookmarkCalled, isTrue);
100
+
101
+
await tester.tap(find.byTooltip('Reply'));
102
+
expect(replyCalled, isTrue);
103
+
});
104
+
36
105
group('viewer interaction states', () {
37
106
testWidgets('shows filled like icon when viewerLikeUri is present', (tester) async {
38
107
await tester.pumpWidget(
+100
test/src/infrastructure/db/daos/feed_content_dao_test.dart
+100
test/src/infrastructure/db/daos/feed_content_dao_test.dart
···
1
+
import 'dart:convert';
2
+
3
+
import 'package:drift/drift.dart' hide isNull, isNotNull;
4
+
import 'package:drift/native.dart';
5
+
import 'package:flutter_test/flutter_test.dart';
6
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
7
+
import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart';
8
+
9
+
void main() {
10
+
late AppDatabase db;
11
+
late FeedContentDao dao;
12
+
13
+
setUp(() {
14
+
db = AppDatabase(NativeDatabase.memory());
15
+
dao = db.feedContentDao;
16
+
});
17
+
18
+
tearDown(() async {
19
+
await db.close();
20
+
});
21
+
22
+
const ownerDid = 'did:plc:owner';
23
+
const feedKey = 'home';
24
+
const postUri = 'at://did:plc:author/app.bsky.feed.post/123';
25
+
26
+
group('FeedContentDao with Interactions', () {
27
+
test('watchFeedContent joins correctly and picks up local interaction', () async {
28
+
await db
29
+
.into(db.profiles)
30
+
.insert(
31
+
ProfilesCompanion.insert(
32
+
did: 'did:plc:author',
33
+
handle: 'author.bsky.social',
34
+
indexedAt: Value(DateTime.now()),
35
+
),
36
+
);
37
+
38
+
await db
39
+
.into(db.posts)
40
+
.insert(
41
+
PostsCompanion.insert(
42
+
uri: postUri,
43
+
cid: 'cid123',
44
+
authorDid: 'did:plc:author',
45
+
record: jsonEncode({'text': 'Hello world'}),
46
+
indexedAt: Value(DateTime.now()),
47
+
),
48
+
);
49
+
50
+
await db
51
+
.into(db.feedContentItems)
52
+
.insert(
53
+
FeedContentItemsCompanion.insert(
54
+
feedKey: feedKey,
55
+
postUri: postUri,
56
+
ownerDid: ownerDid,
57
+
sortKey: DateTime.now().toIso8601String(),
58
+
),
59
+
);
60
+
61
+
final firstResults = await dao.watchFeedContent(feedKey, ownerDid).first;
62
+
expect(firstResults.length, 1);
63
+
expect(firstResults.first.interaction, isNull);
64
+
65
+
await db.postInteractionsDao.upsertInteraction(
66
+
PostInteractionsCompanion.insert(
67
+
postUri: postUri,
68
+
ownerDid: ownerDid,
69
+
likeUri: const Value('at://did:plc:owner/app.bsky.feed.like/456'),
70
+
updatedAt: DateTime.now(),
71
+
),
72
+
);
73
+
74
+
final secondResults = await dao.watchFeedContent(feedKey, ownerDid).first;
75
+
expect(secondResults.length, 1);
76
+
expect(secondResults.first.interaction, isNotNull);
77
+
expect(
78
+
secondResults.first.interaction?.likeUri,
79
+
'at://did:plc:owner/app.bsky.feed.like/456',
80
+
);
81
+
});
82
+
83
+
test('bookmarkUri persistence works', () async {
84
+
await db.postInteractionsDao.upsertInteraction(
85
+
PostInteractionsCompanion.insert(
86
+
postUri: postUri,
87
+
ownerDid: ownerDid,
88
+
bookmarked: const Value(true),
89
+
bookmarkUri: const Value('at://did:plc:owner/app.bsky.bookmark/abc'),
90
+
updatedAt: DateTime.now(),
91
+
),
92
+
);
93
+
94
+
final interaction = await db.postInteractionsDao.getInteraction(postUri, ownerDid);
95
+
expect(interaction, isNotNull);
96
+
expect(interaction?.bookmarked, isTrue);
97
+
expect(interaction?.bookmarkUri, 'at://did:plc:owner/app.bsky.bookmark/abc');
98
+
});
99
+
});
100
+
}