mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

refactor: move profile & thread features to layered dirs

+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 ================================================================================ 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
··· 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
··· 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
··· 18 18 final uuid = _uuid.v4(); 19 19 options.extra['debug_uuid'] = uuid; 20 20 options.extra['debug_start_time'] = DateTime.now().millisecondsSinceEpoch; 21 - 22 - // TODO: add pending status & log pending requests, updated on completion 23 21 handler.next(options); 24 22 } 25 23
+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
··· 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
··· 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
··· 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 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 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
··· 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 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
··· 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
··· 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
··· 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
··· 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 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
··· 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
··· 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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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 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
··· 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 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 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
··· 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
··· 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
··· 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 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() {