+115
doc/ARCHITECTURE.md
+115
doc/ARCHITECTURE.md
···
9
9
(each feature owns its domain/data/presentation)
10
10
- Reactive data flow using Riverpod providers and Drift streams
11
11
12
+
## Feature Layer Architecture
13
+
14
+
All features follow a three-layer architecture for clear separation of concerns:
15
+
16
+
### Domain Layer (`domain/`)
17
+
18
+
**Purpose:** Business logic and data models independent of framework implementation.
19
+
20
+
**Contains:**
21
+
22
+
- Domain models (plain Dart classes, freezed/json_serializable DTOs)
23
+
- Business rules and validation logic
24
+
- Feature-specific exceptions
25
+
26
+
**Rules:**
27
+
28
+
- No Flutter imports (package:flutter)
29
+
- No infrastructure dependencies (Drift, Dio, providers)
30
+
- Pure Dart code that could run in any Dart environment
31
+
- Models represent the problem domain, not API schemas or database tables
32
+
33
+
### Infrastructure Layer (`infrastructure/`)
34
+
35
+
**Purpose:** External integrations and data persistence.
36
+
37
+
**Contains:**
38
+
39
+
- Repository implementations (API clients, database DAOs)
40
+
- Network request/response handling
41
+
- Drift table definitions and database queries
42
+
- Data mapping between domain models and external schemas
43
+
- Cache management and synchronization logic
44
+
45
+
**Rules:**
46
+
47
+
- Implements contracts defined by domain (if using abstract repositories)
48
+
- Handles API-specific details (cursors, pagination, error codes)
49
+
- Manages ownerDid scoping for multi-account isolation
50
+
- Returns domain models, not API DTOs or Drift entities directly
51
+
52
+
### Application Layer (`application/`)
53
+
54
+
**Purpose:** State management and coordination between UI and infrastructure.
55
+
56
+
**Contains:**
57
+
58
+
- Riverpod providers and notifiers
59
+
- UI state classes (loading, error, data states)
60
+
- Orchestration logic combining multiple repositories
61
+
- Application-level business rules (e.g., sync triggers, cache invalidation)
62
+
63
+
**Rules:**
64
+
65
+
- Consumes infrastructure repositories via dependency injection
66
+
- Exposes reactive streams or state for UI consumption
67
+
- No direct Drift queries (delegate to repositories)
68
+
- No UI widgets (widgets belong in presentation/)
69
+
70
+
### Presentation Layer (`presentation/`)
71
+
72
+
**Purpose:** UI components and user interaction handling.
73
+
74
+
**Contains:**
75
+
76
+
- Screens, pages, and widget trees
77
+
- UI-specific state (form controllers, animation controllers)
78
+
- Navigation logic
79
+
- User input handling and validation
80
+
81
+
**Rules:**
82
+
83
+
- Consumes application providers, never repositories directly
84
+
- No business logic beyond UI concerns (visibility, formatting, validation feedback)
85
+
- No direct database or network access
86
+
87
+
### Feature Organization Example
88
+
89
+
```sh
90
+
features
91
+
└── feeds
92
+
├── domain # Domain models
93
+
│ ├── feed.dart
94
+
│ └── feed_post.dart
95
+
├── infrastructure # API + caches
96
+
│ ├── feed_repository.dart
97
+
│ └── feed_content_repository.dart
98
+
├── application # Riverpod state
99
+
│ ├── feed_notifier.dart
100
+
│ └── feed_sync_controller.dart
101
+
└── presentation # UI/Widgets
102
+
├── feed_screen.dart
103
+
└── feed_post_card.dart
104
+
105
+
```
106
+
107
+
### Migration Strategy
108
+
109
+
Features missing layers should be refactored incrementally:
110
+
111
+
1. Extract domain models from presentation or infrastructure
112
+
2. Move API/database logic into repositories
113
+
3. Create application notifiers to coordinate infrastructure
114
+
4. Update presentation to consume application providers only
115
+
116
+
### Cross-Cutting Infrastructure
117
+
118
+
Some infrastructure components serve multiple features and live in `lib/src/infrastructure/`:
119
+
120
+
- **Auth** (`infrastructure/auth/`) - OAuth, session management, token refresh
121
+
- **Network** (`infrastructure/network/`) - Dio clients, XRPC, endpoint routing
122
+
- **Database** (`infrastructure/db/`) - Drift setup, shared DAOs, migrations
123
+
- **Identity** (`infrastructure/identity/`) - DID resolution, handle verification
124
+
125
+
Features consume these via dependency injection through application providers.
126
+
12
127
### ATProto Best Practices
13
128
14
129
- Cursor-based pagination everywhere (avoid OFFSET paging for feeds)
+5
-5
doc/roadmap.txt
+5
-5
doc/roadmap.txt
···
5
5
================================================================================
6
6
7
7
Phase 1: Code Quality and Architecture
8
-
- [ ] Feature architecture standardization:
9
-
- [ ] Audit all features for missing domain/infrastructure/application
8
+
- [x] Feature architecture standardization:
9
+
- [x] Audit all features for missing domain/infrastructure/application
10
10
layers
11
-
- [ ] Refactor features missing layers (priority: auth, settings,
11
+
- [x] Refactor features missing layers (priority: auth, settings,
12
12
profile, search, thread)
13
-
- [ ] Document architecture patterns in doc/architecture.md
14
-
- [ ] Establish clear guidelines for layer responsibilities
13
+
- [x] Document architecture patterns in doc/architecture.md
14
+
- [x] Establish clear guidelines for layer responsibilities
15
15
- [x] Document testing patterns in doc/testing.md
16
16
- [ ] Type safety improvements:
17
17
- Use freezed for critical models
+2
-2
justfile
+2
-2
justfile
···
8
8
9
9
# Test with failures only to focus on failures and hanging tests
10
10
test-quiet *paths='':
11
-
flutter test {{ paths }} --reporter=failures-only --timeout=90s
11
+
flutter test {{ paths }} --reporter=failures-only --timeout=120s
12
12
13
13
# Run all tests
14
14
test *paths='':
15
-
flutter test {{ paths }} --timeout=90s
15
+
flutter test {{ paths }} --timeout=120s
16
16
17
17
# Run code gen
18
18
gen:
+1
-1
lib/src/features/composer/application/composer_notifier.dart
+1
-1
lib/src/features/composer/application/composer_notifier.dart
···
4
4
import 'package:lazurite/src/features/composer/domain/draft.dart';
5
5
import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart';
6
6
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
7
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
7
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
8
8
import 'package:riverpod_annotation/riverpod_annotation.dart';
9
9
10
10
import 'composer_providers.dart';
-2
lib/src/features/debug/infrastructure/debug_network_interceptor.dart
-2
lib/src/features/debug/infrastructure/debug_network_interceptor.dart
+1
lib/src/features/profile/application/profile_providers.dart
+1
lib/src/features/profile/application/profile_providers.dart
···
2
2
import 'package:lazurite/src/core/utils/logger_provider.dart';
3
3
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
4
4
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
5
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
5
6
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
6
7
import 'package:lazurite/src/infrastructure/network/providers.dart';
7
8
import 'package:riverpod_annotation/riverpod_annotation.dart';
+326
lib/src/features/profile/domain/profile.dart
+326
lib/src/features/profile/domain/profile.dart
···
1
+
/// Domain model for profile data.
2
+
class ProfileData {
3
+
factory ProfileData.fromJson(Map<String, dynamic> json) {
4
+
final viewer = json['viewer'] as Map<String, dynamic>?;
5
+
final labels = json['labels'] as List?;
6
+
7
+
return ProfileData(
8
+
did: json['did'] as String,
9
+
handle: json['handle'] as String,
10
+
displayName: json['displayName'] as String?,
11
+
description: json['description'] as String?,
12
+
avatar: json['avatar'] as String?,
13
+
banner: json['banner'] as String?,
14
+
followersCount: json['followersCount'] as int? ?? 0,
15
+
followsCount: json['followsCount'] as int? ?? 0,
16
+
postsCount: json['postsCount'] as int? ?? 0,
17
+
indexedAt: json['indexedAt'] != null ? DateTime.tryParse(json['indexedAt'] as String) : null,
18
+
pronouns: json['pronouns'] as String?,
19
+
website: json['website'] as String?,
20
+
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null,
21
+
verificationStatus: json['verification']?['type'] as String?,
22
+
labels: labels?.cast<Map<String, dynamic>>(),
23
+
pinnedPostUri: json['pinnedPost']?['uri'] as String?,
24
+
viewerFollowing: viewer?['following'] != null,
25
+
viewerFollowUri: viewer?['following'] as String?,
26
+
viewerMuted: viewer?['muted'] as bool? ?? false,
27
+
viewerBlockedBy: viewer?['blockedBy'] as bool? ?? false,
28
+
viewerBlockingUri: viewer?['blocking'] as String?,
29
+
viewerFollowedBy: viewer?['followedBy'] != null,
30
+
viewerMutedByList: viewer?['mutedByList']?['uri'] as String?,
31
+
viewerBlockingByList: viewer?['blockingByList']?['uri'] as String?,
32
+
);
33
+
}
34
+
35
+
ProfileData({
36
+
required this.did,
37
+
required this.handle,
38
+
this.displayName,
39
+
this.description,
40
+
this.avatar,
41
+
this.banner,
42
+
this.followersCount = 0,
43
+
this.followsCount = 0,
44
+
this.postsCount = 0,
45
+
this.indexedAt,
46
+
this.pronouns,
47
+
this.website,
48
+
this.createdAt,
49
+
this.verificationStatus,
50
+
this.labels,
51
+
this.pinnedPostUri,
52
+
this.viewerFollowing = false,
53
+
this.viewerFollowUri,
54
+
this.viewerMuted = false,
55
+
this.viewerBlockedBy = false,
56
+
this.viewerBlockingUri,
57
+
this.viewerFollowedBy = false,
58
+
this.viewerMutedByList,
59
+
this.viewerBlockingByList,
60
+
});
61
+
62
+
final String did;
63
+
final String handle;
64
+
final String? displayName;
65
+
final String? description;
66
+
final String? avatar;
67
+
final String? banner;
68
+
final int followersCount;
69
+
final int followsCount;
70
+
final int postsCount;
71
+
final DateTime? indexedAt;
72
+
final String? pronouns;
73
+
final String? website;
74
+
final DateTime? createdAt;
75
+
final String? verificationStatus;
76
+
final List<Map<String, dynamic>>? labels;
77
+
final String? pinnedPostUri;
78
+
79
+
final bool viewerFollowing;
80
+
final String? viewerFollowUri;
81
+
final bool viewerMuted;
82
+
final bool viewerBlockedBy;
83
+
final String? viewerBlockingUri;
84
+
final bool viewerFollowedBy;
85
+
final String? viewerMutedByList;
86
+
final String? viewerBlockingByList;
87
+
88
+
String get displayNameOrHandle => displayName ?? handle;
89
+
90
+
ProfileData copyWith({
91
+
String? pronouns,
92
+
String? website,
93
+
DateTime? createdAt,
94
+
String? verificationStatus,
95
+
String? pinnedPostUri,
96
+
bool? viewerFollowing,
97
+
String? viewerFollowUri,
98
+
bool? viewerMuted,
99
+
bool? viewerBlockedBy,
100
+
bool? viewerFollowedBy,
101
+
dynamic viewerBlockingUri = _sentinel,
102
+
}) {
103
+
return ProfileData(
104
+
did: did,
105
+
handle: handle,
106
+
displayName: displayName,
107
+
description: description,
108
+
avatar: avatar,
109
+
banner: banner,
110
+
followersCount: followersCount,
111
+
followsCount: followsCount,
112
+
postsCount: postsCount,
113
+
indexedAt: indexedAt,
114
+
pronouns: pronouns ?? this.pronouns,
115
+
website: website ?? this.website,
116
+
createdAt: createdAt ?? this.createdAt,
117
+
verificationStatus: verificationStatus ?? this.verificationStatus,
118
+
labels: labels,
119
+
pinnedPostUri: pinnedPostUri ?? this.pinnedPostUri,
120
+
viewerFollowing: viewerFollowing ?? this.viewerFollowing,
121
+
viewerFollowUri: viewerFollowUri ?? this.viewerFollowUri,
122
+
viewerMuted: viewerMuted ?? this.viewerMuted,
123
+
viewerBlockedBy: viewerBlockedBy ?? this.viewerBlockedBy,
124
+
viewerBlockingUri: viewerBlockingUri == _sentinel
125
+
? this.viewerBlockingUri
126
+
: viewerBlockingUri as String?,
127
+
viewerFollowedBy: viewerFollowedBy ?? this.viewerFollowedBy,
128
+
viewerMutedByList: viewerMutedByList,
129
+
viewerBlockingByList: viewerBlockingByList,
130
+
);
131
+
}
132
+
}
133
+
134
+
const _sentinel = Object();
135
+
136
+
/// Basic actor information for follow lists.
137
+
class ActorBasic {
138
+
factory ActorBasic.fromJson(Map<String, dynamic> json) {
139
+
return ActorBasic(
140
+
did: json['did'] as String,
141
+
handle: json['handle'] as String,
142
+
displayName: json['displayName'] as String?,
143
+
avatar: json['avatar'] as String?,
144
+
);
145
+
}
146
+
147
+
ActorBasic({required this.did, required this.handle, this.displayName, this.avatar});
148
+
149
+
final String did;
150
+
final String handle;
151
+
final String? displayName;
152
+
final String? avatar;
153
+
154
+
String get displayNameOrHandle => displayName ?? handle;
155
+
}
156
+
157
+
/// Represents a single feed item from author feed.
158
+
class FeedItem {
159
+
factory FeedItem.fromPostView(Map<String, dynamic> json) {
160
+
final author = json['author'] as Map<String, dynamic>;
161
+
final record = json['record'] as Map<String, dynamic>;
162
+
final embed = json['embed'] as Map<String, dynamic>?;
163
+
final embedType = embed?[r'$type'] as String?;
164
+
final hasImages =
165
+
embedType == 'app.bsky.embed.images#view' ||
166
+
embedType == 'app.bsky.embed.recordWithMedia#view' &&
167
+
(embed?['media'] as Map<String, dynamic>?)?[r'$type'] == 'app.bsky.embed.images#view';
168
+
final hasVideo = embedType == 'app.bsky.embed.video#view';
169
+
final viewer = json['viewer'] as Map<String, dynamic>?;
170
+
final isQuote = _isQuoteEmbed(embedType);
171
+
172
+
return FeedItem(
173
+
uri: json['uri'] as String,
174
+
cid: json['cid'] as String,
175
+
authorDid: author['did'] as String,
176
+
authorHandle: author['handle'] as String,
177
+
authorDisplayName: author['displayName'] as String?,
178
+
authorAvatar: author['avatar'] as String?,
179
+
text: record['text'] as String? ?? '',
180
+
indexedAt: DateTime.tryParse(json['indexedAt'] as String? ?? ''),
181
+
replyCount: json['replyCount'] as int? ?? 0,
182
+
repostCount: json['repostCount'] as int? ?? 0,
183
+
likeCount: json['likeCount'] as int? ?? 0,
184
+
isReply: record['reply'] != null,
185
+
hasImages: hasImages,
186
+
hasVideo: hasVideo,
187
+
embedType: embedType,
188
+
record: record,
189
+
embed: embed,
190
+
viewerLikeUri: viewer?['like'] as String?,
191
+
viewerRepostUri: viewer?['repost'] as String?,
192
+
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
193
+
isQuote: isQuote,
194
+
);
195
+
}
196
+
197
+
factory FeedItem.fromJson(Map<String, dynamic> json) {
198
+
final post = json['post'] as Map<String, dynamic>;
199
+
final author = post['author'] as Map<String, dynamic>;
200
+
final record = post['record'] as Map<String, dynamic>;
201
+
final reply = record['reply'] as Map<String, dynamic>?;
202
+
final isReply = reply != null;
203
+
final embed = post['embed'] as Map<String, dynamic>?;
204
+
final embedType = embed?[r'$type'] as String?;
205
+
final hasImages =
206
+
embedType == 'app.bsky.embed.images#view' ||
207
+
embedType == 'app.bsky.embed.recordWithMedia#view' &&
208
+
(embed?['media'] as Map<String, dynamic>?)?[r'$type'] == 'app.bsky.embed.images#view';
209
+
final hasVideo = embedType == 'app.bsky.embed.video#view';
210
+
final viewer = post['viewer'] as Map<String, dynamic>?;
211
+
final reason = json['reason'] as Map<String, dynamic>?;
212
+
final isRepost = (reason?[r'$type'] as String?)?.contains('reasonRepost') ?? false;
213
+
final isQuote = _isQuoteEmbed(embedType);
214
+
215
+
return FeedItem(
216
+
uri: post['uri'] as String,
217
+
cid: post['cid'] as String,
218
+
authorDid: author['did'] as String,
219
+
authorHandle: author['handle'] as String,
220
+
authorDisplayName: author['displayName'] as String?,
221
+
authorAvatar: author['avatar'] as String?,
222
+
text: record['text'] as String? ?? '',
223
+
indexedAt: DateTime.tryParse(post['indexedAt'] as String? ?? ''),
224
+
replyCount: post['replyCount'] as int? ?? 0,
225
+
repostCount: post['repostCount'] as int? ?? 0,
226
+
likeCount: post['likeCount'] as int? ?? 0,
227
+
isReply: isReply,
228
+
hasImages: hasImages,
229
+
hasVideo: hasVideo,
230
+
embedType: embedType,
231
+
record: record,
232
+
embed: embed,
233
+
viewerLikeUri: viewer?['like'] as String?,
234
+
viewerRepostUri: viewer?['repost'] as String?,
235
+
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
236
+
isRepost: isRepost,
237
+
isQuote: isQuote,
238
+
);
239
+
}
240
+
241
+
FeedItem({
242
+
required this.uri,
243
+
required this.cid,
244
+
required this.authorDid,
245
+
required this.authorHandle,
246
+
this.authorDisplayName,
247
+
this.authorAvatar,
248
+
required this.text,
249
+
this.indexedAt,
250
+
this.replyCount = 0,
251
+
this.repostCount = 0,
252
+
this.likeCount = 0,
253
+
this.isReply = false,
254
+
this.hasImages = false,
255
+
this.hasVideo = false,
256
+
this.embedType,
257
+
this.record,
258
+
this.embed,
259
+
this.viewerLikeUri,
260
+
this.viewerRepostUri,
261
+
this.viewerBookmarked = false,
262
+
this.isRepost = false,
263
+
this.isQuote = false,
264
+
});
265
+
266
+
final String uri;
267
+
final String cid;
268
+
final String authorDid;
269
+
final String authorHandle;
270
+
final String? authorDisplayName;
271
+
final String? authorAvatar;
272
+
final String text;
273
+
final DateTime? indexedAt;
274
+
final int replyCount;
275
+
final int repostCount;
276
+
final int likeCount;
277
+
278
+
final bool isReply;
279
+
final bool hasImages;
280
+
final bool hasVideo;
281
+
final String? embedType;
282
+
final Map<String, dynamic>? record;
283
+
final Map<String, dynamic>? embed;
284
+
final String? viewerLikeUri;
285
+
final String? viewerRepostUri;
286
+
final bool viewerBookmarked;
287
+
final bool isRepost;
288
+
final bool isQuote;
289
+
290
+
bool get hasMedia => hasImages || hasVideo;
291
+
292
+
static bool _isQuoteEmbed(String? embedType) {
293
+
if (embedType == null) return false;
294
+
return embedType.startsWith('app.bsky.embed.record');
295
+
}
296
+
}
297
+
298
+
/// Result of fetching author feed.
299
+
class AuthorFeedResult {
300
+
AuthorFeedResult({required this.items, this.cursor});
301
+
302
+
final List<FeedItem> items;
303
+
final String? cursor;
304
+
305
+
bool get hasMore => cursor != null;
306
+
}
307
+
308
+
/// Result of fetching followers.
309
+
class FollowersResult {
310
+
FollowersResult({required this.followers, this.cursor});
311
+
312
+
final List<ActorBasic> followers;
313
+
final String? cursor;
314
+
315
+
bool get hasMore => cursor != null;
316
+
}
317
+
318
+
/// Result of fetching follows.
319
+
class FollowsResult {
320
+
FollowsResult({required this.follows, this.cursor});
321
+
322
+
final List<ActorBasic> follows;
323
+
final String? cursor;
324
+
325
+
bool get hasMore => cursor != null;
326
+
}
+2
-349
lib/src/features/profile/infrastructure/profile_repository.dart
+2
-349
lib/src/features/profile/infrastructure/profile_repository.dart
···
8
8
import 'package:lazurite/src/infrastructure/db/daos/profile_relationship_dao.dart';
9
9
import 'package:lazurite/src/infrastructure/network/xrpc_client.dart';
10
10
11
+
import '../domain/profile.dart';
12
+
11
13
/// Repository for profile data with cache-first reads.
12
14
class ProfileRepository {
13
15
ProfileRepository(this._api, this._dao, this._followsDao, this._relationshipsDao, this._logger);
···
383
385
}
384
386
}
385
387
}
386
-
387
-
class ProfileData {
388
-
factory ProfileData.fromJson(Map<String, dynamic> json) {
389
-
final viewer = json['viewer'] as Map<String, dynamic>?;
390
-
final labels = json['labels'] as List?;
391
-
392
-
return ProfileData(
393
-
did: json['did'] as String,
394
-
handle: json['handle'] as String,
395
-
displayName: json['displayName'] as String?,
396
-
description: json['description'] as String?,
397
-
avatar: json['avatar'] as String?,
398
-
banner: json['banner'] as String?,
399
-
followersCount: json['followersCount'] as int? ?? 0,
400
-
followsCount: json['followsCount'] as int? ?? 0,
401
-
postsCount: json['postsCount'] as int? ?? 0,
402
-
indexedAt: json['indexedAt'] != null ? DateTime.tryParse(json['indexedAt'] as String) : null,
403
-
pronouns: json['pronouns'] as String?,
404
-
website: json['website'] as String?,
405
-
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null,
406
-
verificationStatus: json['verification']?['type'] as String?, // Assuming structure
407
-
labels: labels?.cast<Map<String, dynamic>>(),
408
-
pinnedPostUri: json['pinnedPost']?['uri'] as String?,
409
-
viewerFollowing: viewer?['following'] != null,
410
-
viewerFollowUri: viewer?['following'] as String?,
411
-
viewerMuted: viewer?['muted'] as bool? ?? false,
412
-
viewerBlockedBy: viewer?['blockedBy'] as bool? ?? false,
413
-
viewerBlockingUri: viewer?['blocking'] as String?,
414
-
viewerFollowedBy: viewer?['followedBy'] != null,
415
-
viewerMutedByList: viewer?['mutedByList']?['uri'] as String?,
416
-
viewerBlockingByList: viewer?['blockingByList']?['uri'] as String?,
417
-
);
418
-
}
419
-
420
-
ProfileData({
421
-
required this.did,
422
-
required this.handle,
423
-
this.displayName,
424
-
this.description,
425
-
this.avatar,
426
-
this.banner,
427
-
this.followersCount = 0,
428
-
this.followsCount = 0,
429
-
this.postsCount = 0,
430
-
this.indexedAt,
431
-
this.pronouns,
432
-
this.website,
433
-
this.createdAt,
434
-
this.verificationStatus,
435
-
this.labels,
436
-
this.pinnedPostUri,
437
-
this.viewerFollowing = false,
438
-
this.viewerFollowUri,
439
-
this.viewerMuted = false,
440
-
this.viewerBlockedBy = false,
441
-
this.viewerBlockingUri,
442
-
this.viewerFollowedBy = false,
443
-
this.viewerMutedByList,
444
-
this.viewerBlockingByList,
445
-
});
446
-
447
-
final String did;
448
-
final String handle;
449
-
final String? displayName;
450
-
final String? description;
451
-
final String? avatar;
452
-
final String? banner;
453
-
final int followersCount;
454
-
final int followsCount;
455
-
final int postsCount;
456
-
final DateTime? indexedAt;
457
-
final String? pronouns;
458
-
final String? website;
459
-
final DateTime? createdAt;
460
-
final String? verificationStatus;
461
-
final List<Map<String, dynamic>>? labels;
462
-
final String? pinnedPostUri;
463
-
464
-
final bool viewerFollowing;
465
-
final String? viewerFollowUri;
466
-
final bool viewerMuted;
467
-
final bool viewerBlockedBy;
468
-
final String? viewerBlockingUri;
469
-
final bool viewerFollowedBy;
470
-
final String? viewerMutedByList;
471
-
final String? viewerBlockingByList;
472
-
473
-
String get displayNameOrHandle => displayName ?? handle;
474
-
475
-
ProfileData copyWith({
476
-
String? pronouns,
477
-
String? website,
478
-
DateTime? createdAt,
479
-
String? verificationStatus,
480
-
String? pinnedPostUri,
481
-
bool? viewerFollowing,
482
-
String? viewerFollowUri,
483
-
bool? viewerMuted,
484
-
bool? viewerBlockedBy,
485
-
bool? viewerFollowedBy,
486
-
dynamic viewerBlockingUri = _sentinel, // Use dynamic to detect sentinel
487
-
}) {
488
-
return ProfileData(
489
-
did: did,
490
-
handle: handle,
491
-
displayName: displayName,
492
-
description: description,
493
-
avatar: avatar,
494
-
banner: banner,
495
-
followersCount: followersCount,
496
-
followsCount: followsCount,
497
-
postsCount: postsCount,
498
-
indexedAt: indexedAt,
499
-
pronouns: pronouns ?? this.pronouns,
500
-
website: website ?? this.website,
501
-
createdAt: createdAt ?? this.createdAt,
502
-
verificationStatus: verificationStatus ?? this.verificationStatus,
503
-
labels: labels,
504
-
pinnedPostUri: pinnedPostUri ?? this.pinnedPostUri,
505
-
viewerFollowing: viewerFollowing ?? this.viewerFollowing,
506
-
viewerFollowUri: viewerFollowUri ?? this.viewerFollowUri,
507
-
viewerMuted: viewerMuted ?? this.viewerMuted,
508
-
viewerBlockedBy: viewerBlockedBy ?? this.viewerBlockedBy,
509
-
viewerBlockingUri: viewerBlockingUri == _sentinel
510
-
? this.viewerBlockingUri
511
-
: viewerBlockingUri as String?,
512
-
viewerFollowedBy: viewerFollowedBy ?? this.viewerFollowedBy,
513
-
viewerMutedByList: viewerMutedByList,
514
-
viewerBlockingByList: viewerBlockingByList,
515
-
);
516
-
}
517
-
}
518
-
519
-
const _sentinel = Object();
520
-
521
-
/// Result of fetching author feed.
522
-
class AuthorFeedResult {
523
-
AuthorFeedResult({required this.items, this.cursor});
524
-
525
-
final List<FeedItem> items;
526
-
final String? cursor;
527
-
528
-
bool get hasMore => cursor != null;
529
-
}
530
-
531
-
/// Result of fetching followers.
532
-
class FollowersResult {
533
-
FollowersResult({required this.followers, this.cursor});
534
-
535
-
final List<ActorBasic> followers;
536
-
final String? cursor;
537
-
538
-
bool get hasMore => cursor != null;
539
-
}
540
-
541
-
/// Result of fetching follows.
542
-
class FollowsResult {
543
-
FollowsResult({required this.follows, this.cursor});
544
-
545
-
final List<ActorBasic> follows;
546
-
final String? cursor;
547
-
548
-
bool get hasMore => cursor != null;
549
-
}
550
-
551
-
/// Basic actor information for follow lists.
552
-
class ActorBasic {
553
-
factory ActorBasic.fromJson(Map<String, dynamic> json) {
554
-
return ActorBasic(
555
-
did: json['did'] as String,
556
-
handle: json['handle'] as String,
557
-
displayName: json['displayName'] as String?,
558
-
avatar: json['avatar'] as String?,
559
-
);
560
-
}
561
-
562
-
ActorBasic({required this.did, required this.handle, this.displayName, this.avatar});
563
-
564
-
final String did;
565
-
final String handle;
566
-
final String? displayName;
567
-
final String? avatar;
568
-
569
-
/// Returns display name or handle.
570
-
String get displayNameOrHandle => displayName ?? handle;
571
-
}
572
-
573
-
/// Represents a single feed item from author feed.
574
-
class FeedItem {
575
-
factory FeedItem.fromPostView(Map<String, dynamic> json) {
576
-
final author = json['author'] as Map<String, dynamic>;
577
-
final record = json['record'] as Map<String, dynamic>;
578
-
final embed = json['embed'] as Map<String, dynamic>?;
579
-
final embedType = embed?[r'$type'] as String?;
580
-
final hasImages =
581
-
embedType == 'app.bsky.embed.images#view' ||
582
-
embedType == 'app.bsky.embed.recordWithMedia#view' &&
583
-
(embed?['media'] as Map<String, dynamic>?)?[r'$type'] == 'app.bsky.embed.images#view';
584
-
final hasVideo = embedType == 'app.bsky.embed.video#view';
585
-
final viewer = json['viewer'] as Map<String, dynamic>?;
586
-
final isQuote = _isQuoteEmbed(embedType);
587
-
588
-
return FeedItem(
589
-
uri: json['uri'] as String,
590
-
cid: json['cid'] as String,
591
-
authorDid: author['did'] as String,
592
-
authorHandle: author['handle'] as String,
593
-
authorDisplayName: author['displayName'] as String?,
594
-
authorAvatar: author['avatar'] as String?,
595
-
text: record['text'] as String? ?? '',
596
-
indexedAt: DateTime.tryParse(json['indexedAt'] as String? ?? ''),
597
-
replyCount: json['replyCount'] as int? ?? 0,
598
-
repostCount: json['repostCount'] as int? ?? 0,
599
-
likeCount: json['likeCount'] as int? ?? 0,
600
-
isReply: record['reply'] != null,
601
-
hasImages: hasImages,
602
-
hasVideo: hasVideo,
603
-
embedType: embedType,
604
-
record: record,
605
-
embed: embed,
606
-
viewerLikeUri: viewer?['like'] as String?,
607
-
viewerRepostUri: viewer?['repost'] as String?,
608
-
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
609
-
isQuote: isQuote,
610
-
);
611
-
}
612
-
613
-
factory FeedItem.fromJson(Map<String, dynamic> json) {
614
-
final post = json['post'] as Map<String, dynamic>;
615
-
final author = post['author'] as Map<String, dynamic>;
616
-
final record = post['record'] as Map<String, dynamic>;
617
-
final reply = record['reply'] as Map<String, dynamic>?;
618
-
final isReply = reply != null;
619
-
final embed = post['embed'] as Map<String, dynamic>?;
620
-
final embedType = embed?[r'$type'] as String?;
621
-
final hasImages =
622
-
embedType == 'app.bsky.embed.images#view' ||
623
-
embedType == 'app.bsky.embed.recordWithMedia#view' &&
624
-
(embed?['media'] as Map<String, dynamic>?)?[r'$type'] == 'app.bsky.embed.images#view';
625
-
final hasVideo = embedType == 'app.bsky.embed.video#view';
626
-
final viewer = post['viewer'] as Map<String, dynamic>?;
627
-
final reason = json['reason'] as Map<String, dynamic>?;
628
-
final isRepost = (reason?[r'$type'] as String?)?.contains('reasonRepost') ?? false;
629
-
final isQuote = _isQuoteEmbed(embedType);
630
-
631
-
return FeedItem(
632
-
uri: post['uri'] as String,
633
-
cid: post['cid'] as String,
634
-
authorDid: author['did'] as String,
635
-
authorHandle: author['handle'] as String,
636
-
authorDisplayName: author['displayName'] as String?,
637
-
authorAvatar: author['avatar'] as String?,
638
-
text: record['text'] as String? ?? '',
639
-
indexedAt: DateTime.tryParse(post['indexedAt'] as String? ?? ''),
640
-
replyCount: post['replyCount'] as int? ?? 0,
641
-
repostCount: post['repostCount'] as int? ?? 0,
642
-
likeCount: post['likeCount'] as int? ?? 0,
643
-
isReply: isReply,
644
-
hasImages: hasImages,
645
-
hasVideo: hasVideo,
646
-
embedType: embedType,
647
-
record: record,
648
-
embed: embed,
649
-
viewerLikeUri: viewer?['like'] as String?,
650
-
viewerRepostUri: viewer?['repost'] as String?,
651
-
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
652
-
isRepost: isRepost,
653
-
isQuote: isQuote,
654
-
);
655
-
}
656
-
657
-
FeedItem({
658
-
required this.uri,
659
-
required this.cid,
660
-
required this.authorDid,
661
-
required this.authorHandle,
662
-
this.authorDisplayName,
663
-
this.authorAvatar,
664
-
required this.text,
665
-
this.indexedAt,
666
-
this.replyCount = 0,
667
-
this.repostCount = 0,
668
-
this.likeCount = 0,
669
-
this.isReply = false,
670
-
this.hasImages = false,
671
-
this.hasVideo = false,
672
-
this.embedType,
673
-
this.record,
674
-
this.embed,
675
-
this.viewerLikeUri,
676
-
this.viewerRepostUri,
677
-
this.viewerBookmarked = false,
678
-
this.isRepost = false,
679
-
this.isQuote = false,
680
-
});
681
-
682
-
final String uri;
683
-
final String cid;
684
-
final String authorDid;
685
-
final String authorHandle;
686
-
final String? authorDisplayName;
687
-
final String? authorAvatar;
688
-
final String text;
689
-
final DateTime? indexedAt;
690
-
final int replyCount;
691
-
final int repostCount;
692
-
final int likeCount;
693
-
694
-
/// Whether this post is a reply to another post.
695
-
final bool isReply;
696
-
697
-
/// Whether this post has embedded images.
698
-
final bool hasImages;
699
-
700
-
/// Whether this post has embedded video.
701
-
final bool hasVideo;
702
-
703
-
/// The embed type string (e.g., 'app.bsky.embed.images#view').
704
-
final String? embedType;
705
-
706
-
/// The raw record map.
707
-
final Map<String, dynamic>? record;
708
-
709
-
/// The raw embed map.
710
-
final Map<String, dynamic>? embed;
711
-
712
-
/// URI if viewer has liked this post (non-null = liked).
713
-
final String? viewerLikeUri;
714
-
715
-
/// URI if viewer has reposted this post (non-null = reposted).
716
-
final String? viewerRepostUri;
717
-
718
-
/// Whether viewer has bookmarked this post.
719
-
final bool viewerBookmarked;
720
-
721
-
/// Whether this feed item is a repost of someone else's content.
722
-
final bool isRepost;
723
-
724
-
/// Whether this feed item quotes another record.
725
-
final bool isQuote;
726
-
727
-
/// Whether this post has any media (images or video).
728
-
bool get hasMedia => hasImages || hasVideo;
729
-
730
-
static bool _isQuoteEmbed(String? embedType) {
731
-
if (embedType == null) return false;
732
-
return embedType.startsWith('app.bsky.embed.record');
733
-
}
734
-
}
+1
-1
lib/src/features/profile/presentation/profile_screen.dart
+1
-1
lib/src/features/profile/presentation/profile_screen.dart
···
9
9
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
10
10
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
11
11
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
12
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
12
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
13
13
import 'package:lazurite/src/features/profile/presentation/widgets/follow_button.dart';
14
14
import 'package:lazurite/src/features/profile/presentation/widgets/media_tab.dart';
15
15
import 'package:lazurite/src/features/profile/presentation/widgets/pinned_post_card.dart';
+1
-1
lib/src/features/profile/presentation/widgets/media_tab.dart
+1
-1
lib/src/features/profile/presentation/widgets/media_tab.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:go_router/go_router.dart';
3
3
import 'package:lazurite/src/core/widgets/feed_post_card.dart';
4
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
4
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
5
5
6
6
/// Tab content showing author's posts that contain media (images).
7
7
class MediaTab extends StatefulWidget {
+1
-1
lib/src/features/profile/presentation/widgets/profile_header.dart
+1
-1
lib/src/features/profile/presentation/widgets/profile_header.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:intl/intl.dart';
3
3
import 'package:lazurite/src/core/widgets/avatar.dart';
4
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
4
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
5
5
import 'package:lazurite/src/features/profile/presentation/widgets/profile_labels.dart';
6
6
import 'package:lazurite/src/features/profile/presentation/widgets/profile_relationship_indicator.dart';
7
7
import 'package:lazurite/src/features/profile/presentation/widgets/verification_badge.dart';
+1
-1
lib/src/features/profile/presentation/widgets/profile_posts_tab.dart
+1
-1
lib/src/features/profile/presentation/widgets/profile_posts_tab.dart
···
2
2
import 'package:go_router/go_router.dart';
3
3
import 'package:lazurite/src/core/constants/layout_constants.dart';
4
4
import 'package:lazurite/src/core/utils/date_formatter.dart';
5
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
5
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
6
6
7
7
/// Tab content showing author's posts with infinite scroll.
8
8
class ProfilePostsTab extends StatefulWidget {
+1
-1
lib/src/features/profile/presentation/widgets/replies_tab.dart
+1
-1
lib/src/features/profile/presentation/widgets/replies_tab.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:go_router/go_router.dart';
3
3
import 'package:lazurite/src/core/widgets/feed_post_card.dart';
4
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
4
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
5
5
6
6
/// Tab content showing author's replies (posts that are replies to others).
7
7
class RepliesTab extends StatefulWidget {
+1
lib/src/features/search/application/search_providers.dart
+1
lib/src/features/search/application/search_providers.dart
···
5
5
import 'package:lazurite/src/core/utils/logger_provider.dart';
6
6
import 'package:lazurite/src/core/utils/pagination.dart';
7
7
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
8
+
import 'package:lazurite/src/features/search/domain/search_actor.dart';
8
9
import 'package:lazurite/src/features/search/infrastructure/search_repository.dart';
9
10
import 'package:lazurite/src/infrastructure/network/providers.dart';
10
11
import 'package:riverpod_annotation/riverpod_annotation.dart';
+59
lib/src/features/search/domain/search_actor.dart
+59
lib/src/features/search/domain/search_actor.dart
···
1
+
/// An actor (user) from search results.
2
+
class SearchActorItem {
3
+
SearchActorItem({
4
+
required this.did,
5
+
required this.handle,
6
+
this.displayName,
7
+
this.description,
8
+
this.avatar,
9
+
this.followersCount = 0,
10
+
this.followsCount = 0,
11
+
this.indexedAt,
12
+
this.allowIncoming,
13
+
});
14
+
15
+
factory SearchActorItem.fromJson(Map<String, dynamic> json) {
16
+
final did = json['did'];
17
+
final handle = json['handle'];
18
+
19
+
if (did is! String || did.isEmpty) {
20
+
throw FormatException('SearchActorItem.did must be a non-empty string', json);
21
+
}
22
+
if (handle is! String || handle.isEmpty) {
23
+
throw FormatException('SearchActorItem.handle must be a non-empty string', json);
24
+
}
25
+
26
+
return SearchActorItem(
27
+
did: did,
28
+
handle: handle,
29
+
displayName: json['displayName'] as String?,
30
+
description: json['description'] as String?,
31
+
avatar: json['avatar'] as String?,
32
+
followersCount: json['followersCount'] as int? ?? 0,
33
+
followsCount: json['followsCount'] as int? ?? 0,
34
+
indexedAt: DateTime.tryParse(json['indexedAt'] as String? ?? ''),
35
+
allowIncoming: _parseAllowIncoming(json),
36
+
);
37
+
}
38
+
39
+
static String? _parseAllowIncoming(Map<String, dynamic> json) {
40
+
final associated = json['associated'];
41
+
if (associated is Map<String, dynamic>) {
42
+
final chat = associated['chat'];
43
+
if (chat is Map<String, dynamic>) {
44
+
return chat['allowIncoming'] as String?;
45
+
}
46
+
}
47
+
return null;
48
+
}
49
+
50
+
final String did;
51
+
final String handle;
52
+
final String? displayName;
53
+
final String? description;
54
+
final String? avatar;
55
+
final int followersCount;
56
+
final int followsCount;
57
+
final DateTime? indexedAt;
58
+
final String? allowIncoming;
59
+
}
+2
-59
lib/src/features/search/infrastructure/search_repository.dart
+2
-59
lib/src/features/search/infrastructure/search_repository.dart
···
11
11
import 'package:lazurite/src/infrastructure/db/daos/search_dao.dart';
12
12
import 'package:lazurite/src/infrastructure/network/xrpc_client.dart';
13
13
14
+
import '../domain/search_actor.dart';
15
+
14
16
/// Repository for search functionality.
15
17
class SearchRepository {
16
18
SearchRepository(this._api, this._dao, this._cacheDao, this._sessionStorage, this._logger);
···
291
293
}
292
294
}
293
295
}
294
-
295
-
/// An actor (user) from search results.
296
-
class SearchActorItem {
297
-
SearchActorItem({
298
-
required this.did,
299
-
required this.handle,
300
-
this.displayName,
301
-
this.description,
302
-
this.avatar,
303
-
this.followersCount = 0,
304
-
this.followsCount = 0,
305
-
this.indexedAt,
306
-
this.allowIncoming,
307
-
});
308
-
factory SearchActorItem.fromJson(Map<String, dynamic> json) {
309
-
final did = json['did'];
310
-
final handle = json['handle'];
311
-
312
-
if (did is! String || did.isEmpty) {
313
-
throw FormatException('SearchActorItem.did must be a non-empty string', json);
314
-
}
315
-
if (handle is! String || handle.isEmpty) {
316
-
throw FormatException('SearchActorItem.handle must be a non-empty string', json);
317
-
}
318
-
319
-
return SearchActorItem(
320
-
did: did,
321
-
handle: handle,
322
-
displayName: json['displayName'] as String?,
323
-
description: json['description'] as String?,
324
-
avatar: json['avatar'] as String?,
325
-
followersCount: json['followersCount'] as int? ?? 0,
326
-
followsCount: json['followsCount'] as int? ?? 0,
327
-
indexedAt: DateTime.tryParse(json['indexedAt'] as String? ?? ''),
328
-
allowIncoming: _parseAllowIncoming(json),
329
-
);
330
-
}
331
-
332
-
static String? _parseAllowIncoming(Map<String, dynamic> json) {
333
-
final associated = json['associated'];
334
-
if (associated is Map<String, dynamic>) {
335
-
final chat = associated['chat'];
336
-
if (chat is Map<String, dynamic>) {
337
-
return chat['allowIncoming'] as String?;
338
-
}
339
-
}
340
-
return null;
341
-
}
342
-
343
-
final String did;
344
-
final String handle;
345
-
final String? displayName;
346
-
final String? description;
347
-
final String? avatar;
348
-
final int followersCount;
349
-
final int followsCount;
350
-
final DateTime? indexedAt;
351
-
final String? allowIncoming;
352
-
}
+1
-1
lib/src/features/search/presentation/search_screen.dart
+1
-1
lib/src/features/search/presentation/search_screen.dart
···
8
8
import 'package:lazurite/src/core/widgets/loading_view.dart';
9
9
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_embeds.dart';
10
10
import 'package:lazurite/src/features/search/application/search_providers.dart';
11
-
import 'package:lazurite/src/features/search/infrastructure/search_repository.dart';
11
+
import 'package:lazurite/src/features/search/domain/search_actor.dart';
12
12
import 'package:lazurite/src/features/search/presentation/widgets/recent_search_chips.dart';
13
13
import 'package:lazurite/src/features/search/presentation/widgets/search_bar_widget.dart';
14
14
+1
-1
lib/src/features/thread/application/thread_notifier.dart
+1
-1
lib/src/features/thread/application/thread_notifier.dart
···
1
1
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
2
2
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
3
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
3
4
import 'package:riverpod_annotation/riverpod_annotation.dart';
4
5
5
-
import '../infrastructure/thread_repository.dart';
6
6
import 'thread_providers.dart';
7
7
8
8
part 'thread_notifier.g.dart';
+351
lib/src/features/thread/domain/thread.dart
+351
lib/src/features/thread/domain/thread.dart
···
1
+
import 'dart:convert';
2
+
3
+
import 'package:drift/drift.dart';
4
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
5
+
import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart';
6
+
7
+
/// Domain model for a thread view post.
8
+
class ThreadViewPost {
9
+
factory ThreadViewPost.fromJson(Map<String, dynamic> json) {
10
+
final type = json[r'$type'];
11
+
switch (type) {
12
+
case 'app.bsky.feed.defs#threadViewPost':
13
+
final threadgateJson = json['threadgate'] as Map<String, dynamic>?;
14
+
return ThreadViewPost(
15
+
post: ThreadPost.fromJson(json['post'] as Map<String, dynamic>),
16
+
parent: json['parent'] != null ? ThreadViewPost.fromJson(json['parent']) : null,
17
+
replies:
18
+
(json['replies'] as List?)
19
+
?.map((e) => ThreadViewPost.fromJson(e as Map<String, dynamic>))
20
+
.toList() ??
21
+
[],
22
+
threadgate: threadgateJson != null ? Threadgate.fromJson(threadgateJson) : null,
23
+
);
24
+
case 'app.bsky.feed.defs#blockedPost':
25
+
return ThreadViewPost(
26
+
post: ThreadPost.placeholder(
27
+
uri: json['uri'] as String? ?? 'unknown',
28
+
reason: 'Post blocked',
29
+
isBlocked: true,
30
+
),
31
+
isBlocked: true,
32
+
);
33
+
case 'app.bsky.feed.defs#notFoundPost':
34
+
return ThreadViewPost(
35
+
post: ThreadPost.placeholder(
36
+
uri: json['uri'] as String? ?? 'unknown',
37
+
reason: 'Post not found',
38
+
isNotFound: true,
39
+
),
40
+
isNotFound: true,
41
+
);
42
+
default:
43
+
return ThreadViewPost(
44
+
post: ThreadPost.placeholder(
45
+
uri: json['uri'] as String? ?? 'unknown',
46
+
reason: 'Unsupported thread item',
47
+
),
48
+
);
49
+
}
50
+
}
51
+
52
+
ThreadViewPost({
53
+
required this.post,
54
+
this.parent,
55
+
this.replies = const [],
56
+
this.threadgate,
57
+
this.isBlocked = false,
58
+
this.isNotFound = false,
59
+
});
60
+
61
+
final ThreadPost post;
62
+
final ThreadViewPost? parent;
63
+
final List<ThreadViewPost> replies;
64
+
final Threadgate? threadgate;
65
+
final bool isBlocked;
66
+
final bool isNotFound;
67
+
68
+
List<ThreadViewPost> get ancestorChain {
69
+
final chain = <ThreadViewPost>[];
70
+
var current = parent;
71
+
while (current != null) {
72
+
chain.add(current);
73
+
current = current.parent;
74
+
}
75
+
return chain.reversed.toList();
76
+
}
77
+
}
78
+
79
+
/// Domain model for a thread post.
80
+
class ThreadPost {
81
+
ThreadPost({
82
+
required this.uri,
83
+
required this.cid,
84
+
required this.author,
85
+
required this.record,
86
+
this.embed,
87
+
this.indexedAt,
88
+
this.replyCount = 0,
89
+
this.repostCount = 0,
90
+
this.likeCount = 0,
91
+
this.quoteCount = 0,
92
+
this.bookmarkCount = 0,
93
+
this.labels,
94
+
this.viewerLikeUri,
95
+
this.viewerRepostUri,
96
+
this.viewerBookmarked = false,
97
+
this.viewerThreadMuted = false,
98
+
this.viewerReplyDisabled = false,
99
+
this.placeholderReason,
100
+
this.isBlocked = false,
101
+
this.isNotFound = false,
102
+
});
103
+
104
+
factory ThreadPost.fromJson(Map<String, dynamic> json) {
105
+
final author = ThreadAuthor.fromJson(json['author'] as Map<String, dynamic>);
106
+
final viewer = json['viewer'] as Map<String, dynamic>?;
107
+
final labelsJson = json['labels'] as List?;
108
+
109
+
return ThreadPost(
110
+
uri: json['uri'] as String,
111
+
cid: json['cid'] as String? ?? json['uri'] as String,
112
+
author: author,
113
+
record: (json['record'] as Map<String, dynamic>?) ?? const {},
114
+
embed: json['embed'] != null ? jsonEncode(json['embed']) : null,
115
+
indexedAt: DateTime.tryParse(json['indexedAt'] ?? ''),
116
+
replyCount: json['replyCount'] as int? ?? 0,
117
+
repostCount: json['repostCount'] as int? ?? 0,
118
+
likeCount: json['likeCount'] as int? ?? 0,
119
+
quoteCount: json['quoteCount'] as int? ?? 0,
120
+
bookmarkCount: json['bookmarkCount'] as int? ?? 0,
121
+
labels: labelsJson != null ? jsonEncode(labelsJson) : null,
122
+
viewerLikeUri: viewer?['like'] as String?,
123
+
viewerRepostUri: viewer?['repost'] as String?,
124
+
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
125
+
viewerThreadMuted: viewer?['threadMuted'] as bool? ?? false,
126
+
viewerReplyDisabled: viewer?['replyDisabled'] as bool? ?? false,
127
+
);
128
+
}
129
+
130
+
factory ThreadPost.placeholder({
131
+
required String uri,
132
+
required String reason,
133
+
bool isBlocked = false,
134
+
bool isNotFound = false,
135
+
}) {
136
+
return ThreadPost(
137
+
uri: uri,
138
+
cid: uri,
139
+
author: ThreadAuthor(did: 'placeholder:$uri', handle: 'unknown', displayName: reason),
140
+
record: {'text': reason},
141
+
placeholderReason: reason,
142
+
indexedAt: DateTime.now(),
143
+
isBlocked: isBlocked,
144
+
isNotFound: isNotFound,
145
+
);
146
+
}
147
+
148
+
final String uri;
149
+
final String cid;
150
+
final ThreadAuthor author;
151
+
final Map<String, dynamic> record;
152
+
final String? embed;
153
+
final DateTime? indexedAt;
154
+
final int replyCount;
155
+
final int repostCount;
156
+
final int likeCount;
157
+
final int quoteCount;
158
+
final int bookmarkCount;
159
+
final String? labels;
160
+
final String? viewerLikeUri;
161
+
final String? viewerRepostUri;
162
+
final bool viewerBookmarked;
163
+
final bool viewerThreadMuted;
164
+
final bool viewerReplyDisabled;
165
+
final String? placeholderReason;
166
+
final bool isBlocked;
167
+
final bool isNotFound;
168
+
169
+
PostsCompanion toPostsCompanion() {
170
+
return PostsCompanion.insert(
171
+
uri: uri,
172
+
cid: cid,
173
+
authorDid: author.did,
174
+
record: jsonEncode(record),
175
+
embed: Value(embed),
176
+
indexedAt: Value(indexedAt),
177
+
replyCount: Value(replyCount),
178
+
repostCount: Value(repostCount),
179
+
likeCount: Value(likeCount),
180
+
quoteCount: Value(quoteCount),
181
+
bookmarkCount: Value(bookmarkCount),
182
+
labels: Value(labels),
183
+
viewerLikeUri: Value(viewerLikeUri),
184
+
viewerRepostUri: Value(viewerRepostUri),
185
+
viewerBookmarked: Value(viewerBookmarked),
186
+
viewerThreadMuted: Value(viewerThreadMuted),
187
+
viewerReplyDisabled: Value(viewerReplyDisabled),
188
+
);
189
+
}
190
+
191
+
ProfilesCompanion toProfilesCompanion() {
192
+
return ProfilesCompanion.insert(
193
+
did: author.did,
194
+
handle: author.handle,
195
+
displayName: Value(author.displayName ?? placeholderReason),
196
+
description: Value(author.description),
197
+
avatar: Value(author.avatar),
198
+
indexedAt: Value(indexedAt),
199
+
);
200
+
}
201
+
202
+
ProfileRelationshipsCompanion? toRelationshipCompanion(String ownerDid) {
203
+
final viewer = author.viewer;
204
+
if (viewer == null) return null;
205
+
206
+
return ProfileRelationshipsCompanion.insert(
207
+
ownerDid: ownerDid,
208
+
profileDid: author.did,
209
+
following: Value(viewer['following'] != null),
210
+
followingUri: Value(viewer['following'] as String?),
211
+
followedBy: Value(viewer['followedBy'] != null),
212
+
muted: Value(viewer['muted'] as bool? ?? false),
213
+
blocked: Value(viewer['blocking'] != null),
214
+
blockingUri: Value(viewer['blocking'] as String?),
215
+
blockedBy: Value(viewer['blockedBy'] as bool? ?? false),
216
+
mutedByList: Value(viewer['mutedByList']?['uri'] as String?),
217
+
blockingByList: Value(viewer['blockingByList']?['uri'] as String?),
218
+
updatedAt: DateTime.now(),
219
+
);
220
+
}
221
+
222
+
Post toPostModel() {
223
+
return Post(
224
+
uri: uri,
225
+
cid: cid,
226
+
authorDid: author.did,
227
+
record: jsonEncode(record),
228
+
embed: embed,
229
+
indexedAt: indexedAt,
230
+
replyCount: replyCount,
231
+
repostCount: repostCount,
232
+
likeCount: likeCount,
233
+
quoteCount: quoteCount,
234
+
bookmarkCount: bookmarkCount,
235
+
labels: labels,
236
+
viewerLikeUri: viewerLikeUri,
237
+
viewerRepostUri: viewerRepostUri,
238
+
viewerBookmarked: viewerBookmarked,
239
+
viewerThreadMuted: viewerThreadMuted,
240
+
viewerReplyDisabled: viewerReplyDisabled,
241
+
);
242
+
}
243
+
244
+
Profile toProfileModel() {
245
+
return Profile(
246
+
did: author.did,
247
+
handle: author.handle,
248
+
displayName: author.displayName ?? placeholderReason,
249
+
description: author.description,
250
+
avatar: author.avatar,
251
+
indexedAt: indexedAt,
252
+
);
253
+
}
254
+
255
+
FeedPost toFeedPost({String? reason}) {
256
+
return FeedPost(post: toPostModel(), author: toProfileModel(), reason: reason);
257
+
}
258
+
}
259
+
260
+
/// Domain model for a thread author.
261
+
class ThreadAuthor {
262
+
ThreadAuthor({
263
+
required this.did,
264
+
required this.handle,
265
+
this.displayName,
266
+
this.description,
267
+
this.avatar,
268
+
this.viewer,
269
+
});
270
+
271
+
factory ThreadAuthor.fromJson(Map<String, dynamic> json) {
272
+
return ThreadAuthor(
273
+
did: json['did'] as String,
274
+
handle: json['handle'] as String,
275
+
displayName: json['displayName'] as String?,
276
+
description: json['description'] as String?,
277
+
avatar: json['avatar'] as String?,
278
+
viewer: json['viewer'] as Map<String, dynamic>?,
279
+
);
280
+
}
281
+
282
+
final String did;
283
+
final String handle;
284
+
final String? displayName;
285
+
final String? description;
286
+
final String? avatar;
287
+
final Map<String, dynamic>? viewer;
288
+
}
289
+
290
+
/// Threadgate represents reply restrictions on a post.
291
+
class Threadgate {
292
+
Threadgate({required this.uri, this.cid, this.record, this.lists = const []});
293
+
294
+
factory Threadgate.fromJson(Map<String, dynamic> json) {
295
+
final recordJson = json['record'] as Map<String, dynamic>?;
296
+
final listsJson = json['lists'] as List?;
297
+
298
+
return Threadgate(
299
+
uri: json['uri'] as String? ?? '',
300
+
cid: json['cid'] as String?,
301
+
record: recordJson != null ? ThreadgateRecord.fromJson(recordJson) : null,
302
+
lists: listsJson?.map((e) => e as Map<String, dynamic>).toList() ?? [],
303
+
);
304
+
}
305
+
306
+
final String uri;
307
+
final String? cid;
308
+
final ThreadgateRecord? record;
309
+
final List<Map<String, dynamic>> lists;
310
+
311
+
/// Returns readable description of reply restriction.
312
+
String get restrictionDescription {
313
+
if (record == null) return 'Replies restricted';
314
+
final allowRules = record!.allow;
315
+
if (allowRules.isEmpty) return 'Replies disabled';
316
+
317
+
final descriptions = <String>[];
318
+
for (final rule in allowRules) {
319
+
final type = rule[r'$type'] as String?;
320
+
switch (type) {
321
+
case 'app.bsky.feed.threadgate#mentionRule':
322
+
descriptions.add('mentioned users');
323
+
case 'app.bsky.feed.threadgate#followingRule':
324
+
descriptions.add('accounts the author follows');
325
+
case 'app.bsky.feed.threadgate#listRule':
326
+
descriptions.add('list members');
327
+
default:
328
+
descriptions.add('specific users');
329
+
}
330
+
}
331
+
return 'Replies limited to ${descriptions.join(', ')}';
332
+
}
333
+
}
334
+
335
+
/// Threadgate record with allow rules.
336
+
class ThreadgateRecord {
337
+
ThreadgateRecord({required this.post, this.allow = const [], this.createdAt});
338
+
339
+
factory ThreadgateRecord.fromJson(Map<String, dynamic> json) {
340
+
final allowJson = json['allow'] as List?;
341
+
return ThreadgateRecord(
342
+
post: json['post'] as String? ?? '',
343
+
allow: allowJson?.map((e) => e as Map<String, dynamic>).toList() ?? [],
344
+
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
345
+
);
346
+
}
347
+
348
+
final String post;
349
+
final List<Map<String, dynamic>> allow;
350
+
final DateTime? createdAt;
351
+
}
+2
-347
lib/src/features/thread/infrastructure/thread_repository.dart
+2
-347
lib/src/features/thread/infrastructure/thread_repository.dart
···
1
-
import 'dart:convert';
2
-
3
-
import 'package:drift/drift.dart';
4
1
import 'package:lazurite/src/core/utils/logger.dart';
5
2
import 'package:lazurite/src/infrastructure/db/app_database.dart';
6
3
import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart';
7
4
import 'package:lazurite/src/infrastructure/network/xrpc_client.dart';
5
+
6
+
import '../domain/thread.dart';
8
7
9
8
class ThreadRepository {
10
9
ThreadRepository(this._api, this._dao, this._logger);
···
89
88
}
90
89
}
91
90
}
92
-
93
-
/// Domain model for a thread view post
94
-
class ThreadViewPost {
95
-
factory ThreadViewPost.fromJson(Map<String, dynamic> json) {
96
-
final type = json[r'$type'];
97
-
switch (type) {
98
-
case 'app.bsky.feed.defs#threadViewPost':
99
-
final threadgateJson = json['threadgate'] as Map<String, dynamic>?;
100
-
return ThreadViewPost(
101
-
post: ThreadPost.fromJson(json['post'] as Map<String, dynamic>),
102
-
parent: json['parent'] != null ? ThreadViewPost.fromJson(json['parent']) : null,
103
-
replies:
104
-
(json['replies'] as List?)
105
-
?.map((e) => ThreadViewPost.fromJson(e as Map<String, dynamic>))
106
-
.toList() ??
107
-
[],
108
-
threadgate: threadgateJson != null ? Threadgate.fromJson(threadgateJson) : null,
109
-
);
110
-
case 'app.bsky.feed.defs#blockedPost':
111
-
return ThreadViewPost(
112
-
post: ThreadPost.placeholder(
113
-
uri: json['uri'] as String? ?? 'unknown',
114
-
reason: 'Post blocked',
115
-
isBlocked: true,
116
-
),
117
-
isBlocked: true,
118
-
);
119
-
case 'app.bsky.feed.defs#notFoundPost':
120
-
return ThreadViewPost(
121
-
post: ThreadPost.placeholder(
122
-
uri: json['uri'] as String? ?? 'unknown',
123
-
reason: 'Post not found',
124
-
isNotFound: true,
125
-
),
126
-
isNotFound: true,
127
-
);
128
-
default:
129
-
return ThreadViewPost(
130
-
post: ThreadPost.placeholder(
131
-
uri: json['uri'] as String? ?? 'unknown',
132
-
reason: 'Unsupported thread item',
133
-
),
134
-
);
135
-
}
136
-
}
137
-
138
-
ThreadViewPost({
139
-
required this.post,
140
-
this.parent,
141
-
this.replies = const [],
142
-
this.threadgate,
143
-
this.isBlocked = false,
144
-
this.isNotFound = false,
145
-
});
146
-
147
-
final ThreadPost post;
148
-
final ThreadViewPost? parent;
149
-
final List<ThreadViewPost> replies;
150
-
final Threadgate? threadgate;
151
-
final bool isBlocked;
152
-
final bool isNotFound;
153
-
154
-
List<ThreadViewPost> get ancestorChain {
155
-
final chain = <ThreadViewPost>[];
156
-
var current = parent;
157
-
while (current != null) {
158
-
chain.add(current);
159
-
current = current.parent;
160
-
}
161
-
return chain.reversed.toList();
162
-
}
163
-
}
164
-
165
-
class ThreadPost {
166
-
ThreadPost({
167
-
required this.uri,
168
-
required this.cid,
169
-
required this.author,
170
-
required this.record,
171
-
this.embed,
172
-
this.indexedAt,
173
-
this.replyCount = 0,
174
-
this.repostCount = 0,
175
-
this.likeCount = 0,
176
-
this.quoteCount = 0,
177
-
this.bookmarkCount = 0,
178
-
this.labels,
179
-
this.viewerLikeUri,
180
-
this.viewerRepostUri,
181
-
this.viewerBookmarked = false,
182
-
this.viewerThreadMuted = false,
183
-
this.viewerReplyDisabled = false,
184
-
this.placeholderReason,
185
-
this.isBlocked = false,
186
-
this.isNotFound = false,
187
-
});
188
-
189
-
factory ThreadPost.fromJson(Map<String, dynamic> json) {
190
-
final author = ThreadAuthor.fromJson(json['author'] as Map<String, dynamic>);
191
-
final viewer = json['viewer'] as Map<String, dynamic>?;
192
-
final labelsJson = json['labels'] as List?;
193
-
194
-
return ThreadPost(
195
-
uri: json['uri'] as String,
196
-
cid: json['cid'] as String? ?? json['uri'] as String,
197
-
author: author,
198
-
record: (json['record'] as Map<String, dynamic>?) ?? const {},
199
-
embed: json['embed'] != null ? jsonEncode(json['embed']) : null,
200
-
indexedAt: DateTime.tryParse(json['indexedAt'] ?? ''),
201
-
replyCount: json['replyCount'] as int? ?? 0,
202
-
repostCount: json['repostCount'] as int? ?? 0,
203
-
likeCount: json['likeCount'] as int? ?? 0,
204
-
quoteCount: json['quoteCount'] as int? ?? 0,
205
-
bookmarkCount: json['bookmarkCount'] as int? ?? 0,
206
-
labels: labelsJson != null ? jsonEncode(labelsJson) : null,
207
-
viewerLikeUri: viewer?['like'] as String?,
208
-
viewerRepostUri: viewer?['repost'] as String?,
209
-
viewerBookmarked: viewer?['bookmarked'] as bool? ?? false,
210
-
viewerThreadMuted: viewer?['threadMuted'] as bool? ?? false,
211
-
viewerReplyDisabled: viewer?['replyDisabled'] as bool? ?? false,
212
-
);
213
-
}
214
-
215
-
factory ThreadPost.placeholder({
216
-
required String uri,
217
-
required String reason,
218
-
bool isBlocked = false,
219
-
bool isNotFound = false,
220
-
}) {
221
-
return ThreadPost(
222
-
uri: uri,
223
-
cid: uri,
224
-
author: ThreadAuthor(did: 'placeholder:$uri', handle: 'unknown', displayName: reason),
225
-
record: {'text': reason},
226
-
placeholderReason: reason,
227
-
indexedAt: DateTime.now(),
228
-
isBlocked: isBlocked,
229
-
isNotFound: isNotFound,
230
-
);
231
-
}
232
-
233
-
final String uri;
234
-
final String cid;
235
-
final ThreadAuthor author;
236
-
final Map<String, dynamic> record;
237
-
final String? embed;
238
-
final DateTime? indexedAt;
239
-
final int replyCount;
240
-
final int repostCount;
241
-
final int likeCount;
242
-
final int quoteCount;
243
-
final int bookmarkCount;
244
-
final String? labels;
245
-
final String? viewerLikeUri;
246
-
final String? viewerRepostUri;
247
-
final bool viewerBookmarked;
248
-
final bool viewerThreadMuted;
249
-
final bool viewerReplyDisabled;
250
-
final String? placeholderReason;
251
-
final bool isBlocked;
252
-
final bool isNotFound;
253
-
254
-
PostsCompanion toPostsCompanion() {
255
-
return PostsCompanion.insert(
256
-
uri: uri,
257
-
cid: cid,
258
-
authorDid: author.did,
259
-
record: jsonEncode(record),
260
-
embed: Value(embed),
261
-
indexedAt: Value(indexedAt),
262
-
replyCount: Value(replyCount),
263
-
repostCount: Value(repostCount),
264
-
likeCount: Value(likeCount),
265
-
quoteCount: Value(quoteCount),
266
-
bookmarkCount: Value(bookmarkCount),
267
-
labels: Value(labels),
268
-
viewerLikeUri: Value(viewerLikeUri),
269
-
viewerRepostUri: Value(viewerRepostUri),
270
-
viewerBookmarked: Value(viewerBookmarked),
271
-
viewerThreadMuted: Value(viewerThreadMuted),
272
-
viewerReplyDisabled: Value(viewerReplyDisabled),
273
-
);
274
-
}
275
-
276
-
ProfilesCompanion toProfilesCompanion() {
277
-
return ProfilesCompanion.insert(
278
-
did: author.did,
279
-
handle: author.handle,
280
-
displayName: Value(author.displayName ?? placeholderReason),
281
-
description: Value(author.description),
282
-
avatar: Value(author.avatar),
283
-
indexedAt: Value(indexedAt),
284
-
);
285
-
}
286
-
287
-
ProfileRelationshipsCompanion? toRelationshipCompanion(String ownerDid) {
288
-
final viewer = author.viewer;
289
-
if (viewer == null) return null;
290
-
291
-
return ProfileRelationshipsCompanion.insert(
292
-
ownerDid: ownerDid,
293
-
profileDid: author.did,
294
-
following: Value(viewer['following'] != null),
295
-
followingUri: Value(viewer['following'] as String?),
296
-
followedBy: Value(viewer['followedBy'] != null),
297
-
muted: Value(viewer['muted'] as bool? ?? false),
298
-
blocked: Value(viewer['blocking'] != null),
299
-
blockingUri: Value(viewer['blocking'] as String?),
300
-
blockedBy: Value(viewer['blockedBy'] as bool? ?? false),
301
-
mutedByList: Value(viewer['mutedByList']?['uri'] as String?),
302
-
blockingByList: Value(viewer['blockingByList']?['uri'] as String?),
303
-
updatedAt: DateTime.now(),
304
-
);
305
-
}
306
-
307
-
Post toPostModel() {
308
-
return Post(
309
-
uri: uri,
310
-
cid: cid,
311
-
authorDid: author.did,
312
-
record: jsonEncode(record),
313
-
embed: embed,
314
-
indexedAt: indexedAt,
315
-
replyCount: replyCount,
316
-
repostCount: repostCount,
317
-
likeCount: likeCount,
318
-
quoteCount: quoteCount,
319
-
bookmarkCount: bookmarkCount,
320
-
labels: labels,
321
-
viewerLikeUri: viewerLikeUri,
322
-
viewerRepostUri: viewerRepostUri,
323
-
viewerBookmarked: viewerBookmarked,
324
-
viewerThreadMuted: viewerThreadMuted,
325
-
viewerReplyDisabled: viewerReplyDisabled,
326
-
);
327
-
}
328
-
329
-
Profile toProfileModel() {
330
-
return Profile(
331
-
did: author.did,
332
-
handle: author.handle,
333
-
displayName: author.displayName ?? placeholderReason,
334
-
description: author.description,
335
-
avatar: author.avatar,
336
-
indexedAt: indexedAt,
337
-
);
338
-
}
339
-
340
-
FeedPost toFeedPost({String? reason}) {
341
-
return FeedPost(post: toPostModel(), author: toProfileModel(), reason: reason);
342
-
}
343
-
}
344
-
345
-
class ThreadAuthor {
346
-
ThreadAuthor({
347
-
required this.did,
348
-
required this.handle,
349
-
this.displayName,
350
-
this.description,
351
-
this.avatar,
352
-
this.viewer,
353
-
});
354
-
355
-
factory ThreadAuthor.fromJson(Map<String, dynamic> json) {
356
-
return ThreadAuthor(
357
-
did: json['did'] as String,
358
-
handle: json['handle'] as String,
359
-
displayName: json['displayName'] as String?,
360
-
description: json['description'] as String?,
361
-
avatar: json['avatar'] as String?,
362
-
viewer: json['viewer'] as Map<String, dynamic>?,
363
-
);
364
-
}
365
-
366
-
final String did;
367
-
final String handle;
368
-
final String? displayName;
369
-
final String? description;
370
-
final String? avatar;
371
-
final Map<String, dynamic>? viewer;
372
-
}
373
-
374
-
/// Threadgate represents reply restrictions on a post.
375
-
class Threadgate {
376
-
Threadgate({required this.uri, this.cid, this.record, this.lists = const []});
377
-
378
-
factory Threadgate.fromJson(Map<String, dynamic> json) {
379
-
final recordJson = json['record'] as Map<String, dynamic>?;
380
-
final listsJson = json['lists'] as List?;
381
-
382
-
return Threadgate(
383
-
uri: json['uri'] as String? ?? '',
384
-
cid: json['cid'] as String?,
385
-
record: recordJson != null ? ThreadgateRecord.fromJson(recordJson) : null,
386
-
lists: listsJson?.map((e) => e as Map<String, dynamic>).toList() ?? [],
387
-
);
388
-
}
389
-
390
-
final String uri;
391
-
final String? cid;
392
-
final ThreadgateRecord? record;
393
-
final List<Map<String, dynamic>> lists;
394
-
395
-
/// Returns readable description of reply restriction
396
-
String get restrictionDescription {
397
-
if (record == null) return 'Replies restricted';
398
-
final allowRules = record!.allow;
399
-
if (allowRules.isEmpty) return 'Replies disabled';
400
-
401
-
final descriptions = <String>[];
402
-
for (final rule in allowRules) {
403
-
final type = rule[r'$type'] as String?;
404
-
switch (type) {
405
-
case 'app.bsky.feed.threadgate#mentionRule':
406
-
descriptions.add('mentioned users');
407
-
case 'app.bsky.feed.threadgate#followingRule':
408
-
descriptions.add('accounts the author follows');
409
-
case 'app.bsky.feed.threadgate#listRule':
410
-
descriptions.add('list members');
411
-
default:
412
-
descriptions.add('specific users');
413
-
}
414
-
}
415
-
return 'Replies limited to ${descriptions.join(', ')}';
416
-
}
417
-
}
418
-
419
-
/// Threadgate record with allow rules
420
-
class ThreadgateRecord {
421
-
ThreadgateRecord({required this.post, this.allow = const [], this.createdAt});
422
-
423
-
factory ThreadgateRecord.fromJson(Map<String, dynamic> json) {
424
-
final allowJson = json['allow'] as List?;
425
-
return ThreadgateRecord(
426
-
post: json['post'] as String? ?? '',
427
-
allow: allowJson?.map((e) => e as Map<String, dynamic>).toList() ?? [],
428
-
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
429
-
);
430
-
}
431
-
432
-
final String post;
433
-
final List<Map<String, dynamic>> allow;
434
-
final DateTime? createdAt;
435
-
}
+1
-1
lib/src/features/thread/presentation/thread_screen.dart
+1
-1
lib/src/features/thread/presentation/thread_screen.dart
···
7
7
import 'package:lazurite/src/features/settings/domain/bluesky_preferences.dart';
8
8
import 'package:lazurite/src/features/thread/application/thread_notifier.dart';
9
9
import 'package:lazurite/src/features/thread/application/thread_providers.dart';
10
-
import 'package:lazurite/src/features/thread/infrastructure/thread_repository.dart';
10
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
11
11
import 'package:lazurite/src/features/thread/presentation/widgets/blocked_post_card.dart';
12
12
import 'package:lazurite/src/features/thread/presentation/widgets/not_found_post_card.dart';
13
13
import 'package:lazurite/src/features/thread/presentation/widgets/thread_line_connector.dart';
+1
-2
lib/src/features/thread/presentation/widgets/threadgate_indicator.dart
+1
-2
lib/src/features/thread/presentation/widgets/threadgate_indicator.dart
···
1
1
import 'package:flutter/material.dart';
2
-
3
-
import '../../infrastructure/thread_repository.dart';
2
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
4
3
5
4
/// Displays reply restriction information when a threadgate is present.
6
5
class ThreadgateIndicator extends StatelessWidget {
+1
-1
test/src/app/app_test.dart
+1
-1
test/src/app/app_test.dart
···
13
13
import 'package:lazurite/src/features/feeds/application/feed_providers.dart';
14
14
import 'package:lazurite/src/features/feeds/application/feed_sync_controller.dart';
15
15
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
16
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
16
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
17
17
import 'package:lazurite/src/features/search/application/search_providers.dart';
18
18
import 'package:lazurite/src/features/settings/application/preference_sync_controller.dart';
19
19
import 'package:lazurite/src/features/settings/domain/animation_preferences.dart';
+1
-1
test/src/app/router_test.dart
+1
-1
test/src/app/router_test.dart
···
25
25
import 'package:lazurite/src/features/feeds/application/feed_sync_controller.dart';
26
26
import 'package:lazurite/src/features/notifications/application/notifications_providers.dart';
27
27
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
28
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
28
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
29
29
import 'package:lazurite/src/features/profile/presentation/followers_page.dart';
30
30
import 'package:lazurite/src/features/profile/presentation/following_page.dart';
31
31
import 'package:lazurite/src/features/profile/presentation/profile_screen.dart';
+1
test/src/features/composer/application/composer_notifier_test.dart
+1
test/src/features/composer/application/composer_notifier_test.dart
···
6
6
import 'package:lazurite/src/features/composer/domain/draft.dart';
7
7
import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart';
8
8
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
9
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
9
10
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
10
11
import 'package:mocktail/mocktail.dart';
11
12
+2
-2
test/src/features/composer/presentation/screens/composer_screen_test.dart
+2
-2
test/src/features/composer/presentation/screens/composer_screen_test.dart
···
10
10
import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart';
11
11
import 'package:lazurite/src/features/composer/presentation/screens/composer_screen.dart';
12
12
import 'package:lazurite/src/features/composer/presentation/widgets/character_count_meter.dart';
13
-
import 'package:lazurite/src/features/composer/presentation/widgets/quote_post_card.dart';
14
13
import 'package:lazurite/src/features/composer/presentation/widgets/publish_button.dart';
14
+
import 'package:lazurite/src/features/composer/presentation/widgets/quote_post_card.dart';
15
15
import 'package:lazurite/src/features/composer/presentation/widgets/reply_context_card.dart';
16
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
16
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
17
17
import 'package:mocktail/mocktail.dart';
18
18
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
19
19
+1
-1
test/src/features/profile/application/profile_notifier_test.dart
+1
-1
test/src/features/profile/application/profile_notifier_test.dart
···
2
2
import 'package:flutter_test/flutter_test.dart';
3
3
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
4
4
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
5
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
5
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
6
6
import 'package:mocktail/mocktail.dart';
7
7
8
8
import '../../../../helpers/mocks.dart';
+1
-1
test/src/features/profile/presentation/followers_page_test.dart
+1
-1
test/src/features/profile/presentation/followers_page_test.dart
···
3
3
import 'package:flutter_test/flutter_test.dart';
4
4
import 'package:lazurite/src/core/widgets/actor_row.dart';
5
5
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
6
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
6
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
7
7
import 'package:lazurite/src/features/profile/presentation/followers_page.dart';
8
8
import 'package:mocktail/mocktail.dart';
9
9
+1
-1
test/src/features/profile/presentation/following_page_test.dart
+1
-1
test/src/features/profile/presentation/following_page_test.dart
···
3
3
import 'package:flutter_test/flutter_test.dart';
4
4
import 'package:lazurite/src/core/widgets/actor_row.dart';
5
5
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
6
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
6
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
7
7
import 'package:lazurite/src/features/profile/presentation/following_page.dart';
8
8
import 'package:mocktail/mocktail.dart';
9
9
+1
test/src/features/profile/presentation/profile_screen_regression_test.dart
+1
test/src/features/profile/presentation/profile_screen_regression_test.dart
···
3
3
import 'package:flutter_test/flutter_test.dart';
4
4
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
5
5
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
6
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
6
7
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
7
8
import 'package:lazurite/src/features/profile/presentation/profile_screen.dart';
8
9
import 'package:mocktail/mocktail.dart';
+1
-1
test/src/features/profile/presentation/widgets/media_tab_test.dart
+1
-1
test/src/features/profile/presentation/widgets/media_tab_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_test/flutter_test.dart';
3
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
3
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
4
4
import 'package:lazurite/src/features/profile/presentation/widgets/media_tab.dart';
5
5
6
6
void main() {
+1
-1
test/src/features/profile/presentation/widgets/pinned_post_card_test.dart
+1
-1
test/src/features/profile/presentation/widgets/pinned_post_card_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_test/flutter_test.dart';
3
3
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
4
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
4
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
5
5
import 'package:lazurite/src/features/profile/presentation/widgets/pinned_post_card.dart';
6
6
7
7
import '../../../../../helpers/pump_app.dart';
+1
-1
test/src/features/profile/presentation/widgets/profile_actions_sheet_test.dart
+1
-1
test/src/features/profile/presentation/widgets/profile_actions_sheet_test.dart
···
6
6
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
7
7
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
8
8
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
9
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
9
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
10
10
import 'package:lazurite/src/features/profile/presentation/widgets/profile_actions_sheet.dart';
11
11
import 'package:mocktail/mocktail.dart';
12
12
+1
-1
test/src/features/profile/presentation/widgets/profile_header_test.dart
+1
-1
test/src/features/profile/presentation/widgets/profile_header_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_test/flutter_test.dart';
3
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
3
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
4
4
import 'package:lazurite/src/features/profile/presentation/widgets/profile_header.dart';
5
5
6
6
import '../../../../../helpers/pump_app.dart';
+1
-1
test/src/features/profile/presentation/widgets/replies_tab_test.dart
+1
-1
test/src/features/profile/presentation/widgets/replies_tab_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_test/flutter_test.dart';
3
-
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
3
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
4
4
import 'package:lazurite/src/features/profile/presentation/widgets/replies_tab.dart';
5
5
6
6
void main() {
+1
test/src/features/profile/profile_screen_test.dart
+1
test/src/features/profile/profile_screen_test.dart
···
3
3
import 'package:flutter_test/flutter_test.dart';
4
4
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
5
5
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
6
+
import 'package:lazurite/src/features/profile/domain/profile.dart';
6
7
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
7
8
import 'package:lazurite/src/features/profile/presentation/profile_screen.dart';
8
9
import 'package:mocktail/mocktail.dart';
+1
-1
test/src/features/thread/application/thread_notifier_test.dart
+1
-1
test/src/features/thread/application/thread_notifier_test.dart
···
2
2
import 'package:flutter_test/flutter_test.dart';
3
3
import 'package:lazurite/src/features/thread/application/thread_notifier.dart';
4
4
import 'package:lazurite/src/features/thread/application/thread_providers.dart';
5
-
import 'package:lazurite/src/features/thread/infrastructure/thread_repository.dart';
5
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
6
6
import 'package:mocktail/mocktail.dart';
7
7
8
8
import '../../../../helpers/mocks.dart';
+1
-1
test/src/features/thread/presentation/thread_screen_test.dart
+1
-1
test/src/features/thread/presentation/thread_screen_test.dart
···
5
5
import 'package:lazurite/src/features/settings/application/settings_providers.dart';
6
6
import 'package:lazurite/src/features/settings/domain/bluesky_preferences.dart';
7
7
import 'package:lazurite/src/features/thread/application/thread_providers.dart';
8
-
import 'package:lazurite/src/features/thread/infrastructure/thread_repository.dart';
8
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
9
9
import 'package:lazurite/src/features/thread/presentation/thread_screen.dart';
10
10
import 'package:lazurite/src/features/thread/presentation/widgets/blocked_post_card.dart';
11
11
import 'package:lazurite/src/features/thread/presentation/widgets/not_found_post_card.dart';
+1
-1
test/src/features/thread/presentation/widgets/threadgate_indicator_test.dart
+1
-1
test/src/features/thread/presentation/widgets/threadgate_indicator_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_test/flutter_test.dart';
3
-
import 'package:lazurite/src/features/thread/infrastructure/thread_repository.dart';
3
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
4
4
import 'package:lazurite/src/features/thread/presentation/widgets/threadgate_indicator.dart';
5
5
6
6
void main() {