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

Configure Feed

Select the types of activity you want to include in your feed.

fix: pds resolution for liked posts

* dev tools/explorer route topology

+1758 -327
+5 -1
lib/core/database/app_database.dart
··· 556 556 Future<List<LikedPostEntry>> getLikedPosts(String accountDid, {int limit = 50, int offset = 0}) => 557 557 (select(likedPosts) 558 558 ..where((l) => l.accountDid.equals(accountDid)) 559 - ..orderBy([(l) => OrderingTerm.desc(l.likedAt)]) 559 + ..orderBy([(l) => OrderingTerm.desc(l.likedAt), (l) => OrderingTerm.desc(l.postUri)]) 560 560 ..limit(limit, offset: offset)) 561 561 .get(); 562 562 ··· 565 565 566 566 Future<int> upsertLikedPost(LikedPostsCompanion post) => 567 567 into(likedPosts).insert(post, mode: InsertMode.insertOrIgnore); 568 + 569 + Future<int> updateLikedPost(int id, {required String postJson, required DateTime likedAt}) => (update( 570 + likedPosts, 571 + )..where((l) => l.id.equals(id))).write(LikedPostsCompanion(postJson: Value(postJson), likedAt: Value(likedAt))); 568 572 569 573 Future<int> removeLikedPost(String accountDid, String postUri) => 570 574 (delete(likedPosts)..where((l) => l.accountDid.equals(accountDid) & l.postUri.equals(postUri))).go();
+184 -9
lib/core/embedding/embedding_service.dart
··· 3 3 4 4 import 'package:flutter/foundation.dart'; 5 5 import 'package:flutter/services.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 6 7 import 'package:lazurite/core/embedding/word_piece_tokenizer.dart'; 7 8 import 'package:tflite_flutter/tflite_flutter.dart'; 8 9 ··· 25 26 } 26 27 27 28 Future<Float32List> _runInference( 29 + Interpreter interpreter, 28 30 IsolateInterpreter isolateInterpreter, 29 31 WordPieceTokenizer tokenizer, 30 32 String text, ··· 33 35 const seqLen = WordPieceTokenizer.maxTokens; 34 36 35 37 final inputIds = [tokenIds]; 36 - final attentionMask = [tokenIds.map((id) => id != 0 ? 1 : 0).toList()]; 38 + final attentionMask = [tokenIds.map((id) => id != 0 ? 1 : 0).toList(growable: false)]; 37 39 final tokenTypeIds = [List<int>.filled(seqLen, 0)]; 38 40 39 - final outputBuffer = [List<double>.filled(384, 0.0)]; 40 - await isolateInterpreter.runForMultipleInputs([inputIds, attentionMask, tokenTypeIds], {0: outputBuffer}); 41 + final inputTensors = interpreter.getInputTensors(); 42 + final inputs = _buildModelInputs( 43 + inputTensors, 44 + inputIds: inputIds, 45 + attentionMask: attentionMask, 46 + tokenTypeIds: tokenTypeIds, 47 + ); 48 + 49 + final outputTensors = interpreter.getOutputTensors(); 50 + if (outputTensors.isEmpty) { 51 + throw StateError('Embedding model has no output tensors.'); 52 + } 53 + final outputs = <int, Object>{ 54 + for (var i = 0; i < outputTensors.length; i++) i: _allocateTensorBuffer(outputTensors[i].shape), 55 + }; 56 + await isolateInterpreter.runForMultipleInputs(inputs, outputs); 57 + 58 + final embedding = _extractEmbeddingFromModelOutput(outputs[0], attentionMask.first); 59 + return l2Normalize(embedding); 60 + } 61 + 62 + List<Object> _buildModelInputs( 63 + List<Tensor> inputTensors, { 64 + required List<List<int>> inputIds, 65 + required List<List<int>> attentionMask, 66 + required List<List<int>> tokenTypeIds, 67 + }) { 68 + if (inputTensors.isEmpty) { 69 + return const []; 70 + } 71 + 72 + final inputs = List<Object>.filled(inputTensors.length, inputIds, growable: false); 73 + var assignedInputIds = false; 74 + var assignedAttentionMask = false; 75 + var assignedTokenTypes = false; 76 + 77 + for (var i = 0; i < inputTensors.length; i++) { 78 + final name = inputTensors[i].name.toLowerCase(); 79 + if ((name.contains('input') && name.contains('id') && !name.contains('token_type')) || 80 + (name.contains('token') && name.contains('ids') && !name.contains('type'))) { 81 + inputs[i] = inputIds; 82 + assignedInputIds = true; 83 + continue; 84 + } 85 + if (name.contains('attention') || name.contains('mask')) { 86 + inputs[i] = attentionMask; 87 + assignedAttentionMask = true; 88 + continue; 89 + } 90 + if (name.contains('token_type') || name.contains('segment')) { 91 + inputs[i] = tokenTypeIds; 92 + assignedTokenTypes = true; 93 + continue; 94 + } 95 + } 96 + 97 + // Fallback mapping when tensor names are opaque or stripped. 98 + if (!assignedInputIds && inputTensors.isNotEmpty) { 99 + inputs[0] = inputIds; 100 + } 101 + if (!assignedAttentionMask && inputTensors.length >= 2) { 102 + inputs[1] = attentionMask; 103 + } 104 + if (!assignedTokenTypes && inputTensors.length >= 3) { 105 + inputs[2] = tokenTypeIds; 106 + } 107 + return inputs; 108 + } 109 + 110 + Object _allocateTensorBuffer(List<int> shape) { 111 + final normalizedShape = shape.map((dimension) => dimension > 0 ? dimension : 1).toList(growable: false); 112 + if (normalizedShape.isEmpty) { 113 + return 0.0; 114 + } 115 + return _allocateTensorBufferRecursive(normalizedShape, 0); 116 + } 117 + 118 + Object _allocateTensorBufferRecursive(List<int> shape, int depth) { 119 + final size = shape[depth]; 120 + if (depth == shape.length - 1) { 121 + return List<double>.filled(size, 0.0, growable: false); 122 + } 123 + return List.generate(size, (_) => _allocateTensorBufferRecursive(shape, depth + 1), growable: false); 124 + } 125 + 126 + Float32List _extractEmbeddingFromModelOutput(Object? output, List<int> attentionMask) { 127 + if (output is! List || output.isEmpty) { 128 + throw StateError('Embedding model output is empty or invalid.'); 129 + } 130 + 131 + final first = output.first; 132 + if (first is List<double>) { 133 + return Float32List.fromList(first); 134 + } 41 135 42 - return l2Normalize(Float32List.fromList(outputBuffer[0])); 136 + // [batch, seq, hidden] shape: mean-pool token embeddings. 137 + if (first is List && first.isNotEmpty && first.first is List<double>) { 138 + final tokenRows = first.cast<List<double>>(); 139 + final hiddenSize = tokenRows.first.length; 140 + final pooled = List<double>.filled(hiddenSize, 0.0, growable: false); 141 + var counted = 0; 142 + 143 + for (var i = 0; i < tokenRows.length && i < attentionMask.length; i++) { 144 + if (attentionMask[i] == 0) { 145 + continue; 146 + } 147 + final row = tokenRows[i]; 148 + if (row.length != hiddenSize) { 149 + continue; 150 + } 151 + counted++; 152 + for (var j = 0; j < hiddenSize; j++) { 153 + pooled[j] = pooled[j] + row[j]; 154 + } 155 + } 156 + 157 + if (counted == 0) { 158 + return Float32List(hiddenSize); 159 + } 160 + for (var i = 0; i < hiddenSize; i++) { 161 + pooled[i] = pooled[i] / counted; 162 + } 163 + return Float32List.fromList(pooled); 164 + } 165 + 166 + throw StateError('Embedding model output shape is unsupported.'); 43 167 } 44 168 45 169 /// On-device text embedding service backed by a long-lived background [Isolate]. ··· 66 190 IsolateInterpreter? _isolateInterpreter; 67 191 WordPieceTokenizer? _tokenizer; 68 192 193 + static const String _modelAssetFile = 'all-MiniLM-L6-v2-quant.tflite'; 194 + static const String _vocabAssetFile = 'vocab.txt'; 195 + static const List<String> _modelAssetCandidates = ['assets/$_modelAssetFile', _modelAssetFile]; 196 + static const List<String> _vocabAssetCandidates = ['assets/$_vocabAssetFile', _vocabAssetFile]; 197 + 69 198 /// Whether the service is ready to produce embeddings. 70 199 /// 71 200 /// False until [initialize] completes successfully, and false again after ··· 94 223 Interpreter? interpreter; 95 224 IsolateInterpreter? isolateInterpreter; 96 225 try { 97 - interpreter = await Interpreter.fromAsset('all-MiniLM-L6-v2-quant.tflite'); 226 + interpreter = await _loadInterpreterFromAssets(); 98 227 isolateInterpreter = await IsolateInterpreter.create( 99 228 address: interpreter.address, 100 229 debugName: 'EmbeddingInferenceIsolate', 101 230 ); 102 - final vocabText = await rootBundle.loadString('assets/vocab.txt'); 231 + final vocabText = await _loadVocabFromAssets(); 103 232 final tokenizer = WordPieceTokenizer.fromString(vocabText); 233 + final inputTensors = interpreter.getInputTensors(); 234 + final outputTensors = interpreter.getOutputTensors(); 235 + log.i( 236 + 'EmbeddingService initialized with ${inputTensors.length} input(s): ' 237 + '${inputTensors.map((tensor) => '${tensor.name}${tensor.shape}').join(', ')} ' 238 + 'and ${outputTensors.length} output(s): ' 239 + '${outputTensors.map((tensor) => '${tensor.name}${tensor.shape}').join(', ')}', 240 + ); 104 241 _interpreter = interpreter; 105 242 _isolateInterpreter = isolateInterpreter; 106 243 _tokenizer = tokenizer; 107 244 _isAvailable = true; 108 - } catch (_) { 245 + } catch (error, stackTrace) { 109 246 if (isolateInterpreter != null) { 110 247 await isolateInterpreter.close(); 111 248 } ··· 114 251 _isolateInterpreter = null; 115 252 _tokenizer = null; 116 253 _isAvailable = false; 254 + log.e('EmbeddingService initialization failed', error: error, stackTrace: stackTrace); 117 255 } 118 256 } 119 257 ··· 138 276 } 139 277 140 278 final isolateInterpreter = _isolateInterpreter; 279 + final interpreter = _interpreter; 141 280 final tokenizer = _tokenizer; 142 - if (isolateInterpreter == null || tokenizer == null) { 281 + if (isolateInterpreter == null || interpreter == null || tokenizer == null) { 143 282 throw StateError('EmbeddingService is not fully initialized.'); 144 283 } 145 - return _runInference(isolateInterpreter, tokenizer, text); 284 + return _runInference(interpreter, isolateInterpreter, tokenizer, text); 146 285 } 147 286 148 287 /// Shut down the background isolate and mark the service as unavailable. ··· 159 298 _isolateInterpreter = null; 160 299 _tokenizer = null; 161 300 _initialization = null; 301 + } 302 + 303 + Future<Interpreter> _loadInterpreterFromAssets() async { 304 + Object? lastError; 305 + StackTrace? lastStackTrace; 306 + for (final asset in _modelAssetCandidates) { 307 + try { 308 + return await Interpreter.fromAsset(asset); 309 + } catch (error, stackTrace) { 310 + lastError = error; 311 + lastStackTrace = stackTrace; 312 + } 313 + } 314 + 315 + if (lastError != null && lastStackTrace != null) { 316 + Error.throwWithStackTrace(lastError, lastStackTrace); 317 + } 318 + throw StateError('Unable to load embedding model asset.'); 319 + } 320 + 321 + Future<String> _loadVocabFromAssets() async { 322 + Object? lastError; 323 + StackTrace? lastStackTrace; 324 + for (final asset in _vocabAssetCandidates) { 325 + try { 326 + return await rootBundle.loadString(asset); 327 + } catch (error, stackTrace) { 328 + lastError = error; 329 + lastStackTrace = stackTrace; 330 + } 331 + } 332 + 333 + if (lastError != null && lastStackTrace != null) { 334 + Error.throwWithStackTrace(lastError, lastStackTrace); 335 + } 336 + throw StateError('Unable to load embedding vocabulary asset.'); 162 337 } 163 338 }
+185
lib/core/network/actor_repository_service_resolver.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + import 'dart:io'; 4 + 5 + import 'package:http/http.dart' as http; 6 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 7 + 8 + class ActorRepositoryServiceResolution { 9 + const ActorRepositoryServiceResolution({required this.actor, required this.did, required this.pdsHost}); 10 + 11 + final String actor; 12 + final String did; 13 + final String pdsHost; 14 + } 15 + 16 + const Duration _identityLookupTimeout = Duration(seconds: 10); 17 + const String _defaultResolveHandleHost = 'bsky.social'; 18 + const String _fallbackResolveHandleHost = 'public.api.bsky.app'; 19 + 20 + /// Resolves actor identifiers (DID/handle) to repo DID + PDS service host. 21 + /// 22 + /// Topology requirements for non-self repo reads: 23 + /// - Never use viewer-session `resolveIdentity`. 24 + /// - Resolve handles via public `com.atproto.identity.resolveHandle`. 25 + /// - Resolve DID docs directly (`did:plc` -> PLC directory, `did:web` -> 26 + /// `/.well-known/did.json` or explicit path), then extract `#atproto_pds`. 27 + /// 28 + /// Keeps a small in-memory cache for repeated lookups during the session. 29 + class ActorRepositoryServiceResolver { 30 + ActorRepositoryServiceResolver({http.Client? httpClient, String resolveHandleHost = _defaultResolveHandleHost}) 31 + : _httpClient = httpClient ?? http.Client(), 32 + _resolveHandleHosts = _buildResolveHandleHosts(resolveHandleHost); 33 + final http.Client _httpClient; 34 + final List<String> _resolveHandleHosts; 35 + final Map<String, ActorRepositoryServiceResolution> _cacheByActor = <String, ActorRepositoryServiceResolution>{}; 36 + final Map<String, ActorRepositoryServiceResolution> _cacheByDid = <String, ActorRepositoryServiceResolution>{}; 37 + static const int _maxCacheEntries = 256; 38 + 39 + Future<ActorRepositoryServiceResolution> resolve(String actor) async { 40 + final normalizedActor = actor.trim().toLowerCase(); 41 + if (normalizedActor.isEmpty) { 42 + throw ArgumentError.value(actor, 'actor', 'Actor must not be empty.'); 43 + } 44 + 45 + final cachedByActor = _cacheByActor[normalizedActor]; 46 + if (cachedByActor != null) { 47 + return cachedByActor; 48 + } 49 + 50 + final cachedByDid = _cacheByDid[normalizedActor]; 51 + if (cachedByDid != null) { 52 + _cacheByActor[normalizedActor] = cachedByDid; 53 + return cachedByDid; 54 + } 55 + 56 + final did = _isDid(normalizedActor) ? normalizedActor : await _resolveHandleToDid(normalizedActor); 57 + final didKey = did.toLowerCase(); 58 + final cachedDidResolution = _cacheByDid[didKey]; 59 + if (cachedDidResolution != null) { 60 + _cacheByActor[normalizedActor] = cachedDidResolution; 61 + return cachedDidResolution; 62 + } 63 + 64 + final didDoc = await _resolveDidDocument(did); 65 + final pdsHost = extractAtprotoPdsHostFromDidDoc(didDoc); 66 + if (pdsHost == null || pdsHost.isEmpty) { 67 + throw StateError('Unable to resolve actor PDS host: actor=$actor did=$did'); 68 + } 69 + 70 + final resolution = ActorRepositoryServiceResolution(actor: actor, did: did, pdsHost: pdsHost); 71 + _put(normalizedActor, didKey, resolution); 72 + return resolution; 73 + } 74 + 75 + static List<String> _buildResolveHandleHosts(String preferredHost) { 76 + final seen = <String>{}; 77 + final hosts = <String>[]; 78 + 79 + void addHost(String? candidate) { 80 + final normalized = normalizeAtprotoServiceHost(candidate); 81 + if (normalized == null || normalized.isEmpty) { 82 + return; 83 + } 84 + if (seen.add(normalized)) { 85 + hosts.add(normalized); 86 + } 87 + } 88 + 89 + addHost(preferredHost); 90 + addHost(_defaultResolveHandleHost); 91 + addHost(_fallbackResolveHandleHost); 92 + return hosts; 93 + } 94 + 95 + bool _isDid(String value) => value.startsWith('did:'); 96 + 97 + Future<String> _resolveHandleToDid(String normalizedHandle) async { 98 + final handle = normalizedHandle.replaceFirst(RegExp(r'^@+'), ''); 99 + if (handle.isEmpty) { 100 + throw StateError('Unable to resolve empty handle to DID'); 101 + } 102 + 103 + Object? lastError; 104 + for (final host in _resolveHandleHosts) { 105 + final uri = Uri.https(host, '/xrpc/com.atproto.identity.resolveHandle', {'handle': handle}); 106 + try { 107 + final response = await _httpClient 108 + .get(uri, headers: const {'Accept': 'application/json', 'User-Agent': 'lazurite'}) 109 + .timeout(_identityLookupTimeout); 110 + if (response.statusCode != HttpStatus.ok) { 111 + lastError = StateError( 112 + 'resolveHandle failed for $handle via $host: HTTP ${response.statusCode} ${response.body}', 113 + ); 114 + continue; 115 + } 116 + 117 + final decoded = jsonDecode(response.body); 118 + if (decoded is! Map<String, dynamic>) { 119 + lastError = StateError('resolveHandle returned invalid payload for $handle via $host'); 120 + continue; 121 + } 122 + 123 + final did = (decoded['did'] as String?)?.trim(); 124 + if (did == null || did.isEmpty || !_isDid(did)) { 125 + lastError = StateError('resolveHandle payload missing DID for $handle via $host'); 126 + continue; 127 + } 128 + 129 + return did; 130 + } catch (error) { 131 + lastError = error; 132 + } 133 + } 134 + 135 + throw StateError('Unable to resolve handle to DID: $handle error=$lastError'); 136 + } 137 + 138 + Future<Map<String, dynamic>> _resolveDidDocument(String did) async { 139 + final uri = _didDocumentUri(did); 140 + final response = await _httpClient 141 + .get(uri, headers: const {'Accept': 'application/json', 'User-Agent': 'lazurite'}) 142 + .timeout(_identityLookupTimeout); 143 + if (response.statusCode != HttpStatus.ok) { 144 + throw StateError('Failed to resolve DID document for $did: HTTP ${response.statusCode} (${uri.host})'); 145 + } 146 + 147 + final decoded = jsonDecode(response.body); 148 + if (decoded is! Map<String, dynamic>) { 149 + throw StateError('Invalid DID document payload for $did'); 150 + } 151 + return decoded; 152 + } 153 + 154 + Uri _didDocumentUri(String did) { 155 + if (did.startsWith('did:plc:')) { 156 + return Uri.https('plc.directory', '/$did'); 157 + } 158 + 159 + if (did.startsWith('did:web:')) { 160 + final encodedSegments = did.substring('did:web:'.length).split(':'); 161 + if (encodedSegments.isEmpty || encodedSegments.first.isEmpty) { 162 + throw StateError('Invalid did:web identifier: $did'); 163 + } 164 + 165 + final host = Uri.decodeComponent(encodedSegments.first); 166 + final pathSegments = encodedSegments.skip(1).map(Uri.decodeComponent).toList(growable: false); 167 + final path = pathSegments.isEmpty ? '/.well-known/did.json' : '/${pathSegments.join('/')}/did.json'; 168 + return Uri.https(host, path); 169 + } 170 + 171 + throw StateError('Unsupported DID method for resolver: $did'); 172 + } 173 + 174 + void _put(String actorKey, String didKey, ActorRepositoryServiceResolution resolution) { 175 + _cacheByActor[actorKey] = resolution; 176 + _cacheByDid[didKey] = resolution; 177 + 178 + if (_cacheByActor.length > _maxCacheEntries) { 179 + _cacheByActor.remove(_cacheByActor.keys.first); 180 + } 181 + if (_cacheByDid.length > _maxCacheEntries) { 182 + _cacheByDid.remove(_cacheByDid.keys.first); 183 + } 184 + } 185 + }
+1 -1
lib/core/network/app_bsky_routing_policy.dart
··· 48 48 'app.bsky.feed.searchPosts': AppBskyProxyMode.useProxy, 49 49 'app.bsky.feed.getPostThread': AppBskyProxyMode.useProxy, 50 50 'app.bsky.feed.getAuthorFeed': AppBskyProxyMode.useProxy, 51 - 'app.bsky.feed.getActorLikes': AppBskyProxyMode.useProxy, 52 51 53 52 /// Actor/profile endpoints (public or account-context reads). 54 53 'app.bsky.actor.getProfile': AppBskyProxyMode.bypassProxy, ··· 82 81 'app.bsky.feed.getLikes': AppBskyProxyMode.bypassProxy, 83 82 'app.bsky.feed.getQuotes': AppBskyProxyMode.bypassProxy, 84 83 'app.bsky.feed.getRepostedBy': AppBskyProxyMode.bypassProxy, 84 + 'app.bsky.feed.getActorLikes': AppBskyProxyMode.bypassProxy, 85 85 'app.bsky.feed.getListFeed': AppBskyProxyMode.bypassProxy, 86 86 'app.bsky.feed.getPosts': AppBskyProxyMode.bypassProxy, 87 87 'app.bsky.bookmark.createBookmark': AppBskyProxyMode.bypassProxy,
+21
lib/core/network/atproto_host_resolver.dart
··· 29 29 return trimmed; 30 30 } 31 31 32 + String? extractAtprotoPdsHostFromDidDoc(Map<String, dynamic> didDoc) { 33 + final services = didDoc['service']; 34 + if (services is! List) { 35 + return null; 36 + } 37 + 38 + for (final service in services) { 39 + if (service is! Map<String, dynamic>) { 40 + continue; 41 + } 42 + 43 + if (service['id'] == '#atproto_pds' && 44 + service['type'] == 'AtprotoPersonalDataServer' && 45 + service['serviceEndpoint'] is String) { 46 + return normalizeAtprotoServiceHost(service['serviceEndpoint'] as String); 47 + } 48 + } 49 + 50 + return null; 51 + } 52 + 32 53 String? _resolveOAuthPdsHost(AuthTokens tokens) { 33 54 if (!tokens.usesOAuth || 34 55 tokens.refreshToken == null ||
+74 -14
lib/features/compose/bloc/compose_bloc.dart
··· 13 13 import 'package:flutter_bloc/flutter_bloc.dart'; 14 14 import 'package:lazurite/core/database/app_database.dart'; 15 15 import 'package:lazurite/core/logging/app_logger.dart'; 16 + import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 16 17 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 17 18 import 'package:lazurite/features/compose/data/link_preview_service.dart'; 18 19 ··· 699 700 } 700 701 701 702 class ComposeRepository { 702 - ComposeRepository({required Bluesky bluesky, LinkPreviewService? linkPreviewService}) 703 - : _bluesky = bluesky, 704 - _linkPreviewService = linkPreviewService ?? LinkPreviewService(); 703 + ComposeRepository({ 704 + required Bluesky bluesky, 705 + LinkPreviewService? linkPreviewService, 706 + ActorRepositoryServiceResolver? actorRepositoryServiceResolver, 707 + }) : _actorRepoResolver = actorRepositoryServiceResolver ?? ActorRepositoryServiceResolver(), 708 + _bluesky = bluesky, 709 + _linkPreviewService = linkPreviewService ?? LinkPreviewService(); 705 710 706 711 final Bluesky _bluesky; 707 712 final LinkPreviewService _linkPreviewService; 713 + final ActorRepositoryServiceResolver _actorRepoResolver; 708 714 709 715 Future<BlobRef?> uploadBlob(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 710 716 try { ··· 813 819 }) async { 814 820 try { 815 821 final parentAtUri = AtUri.parse(parentUri); 816 - final parent = await _bluesky.atproto.repo.getRecord( 822 + final parent = await _getRecordFromRepo( 817 823 repo: parentAtUri.hostname, 818 824 collection: parentAtUri.collection.toString(), 819 825 rkey: parentAtUri.rkey, 820 826 ); 821 827 822 - final latestParentCid = parent.data.cid ?? parentCid; 823 - final parentReply = parent.data.value['reply']; 828 + final latestParentCidRaw = parent.data.cid; 829 + final latestParentCid = latestParentCidRaw is String && latestParentCidRaw.isNotEmpty 830 + ? latestParentCidRaw 831 + : parentCid; 832 + final parentValue = parent.data.value; 833 + if (parentValue is! Map<String, dynamic>) { 834 + return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); 835 + } 836 + 837 + final parentReply = parentValue['reply']; 824 838 if (parentReply is! Map) { 825 839 return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); 826 840 } ··· 869 883 final targetRepo = atUri.hostname.isNotEmpty ? atUri.hostname : repo; 870 884 final collection = atUri.collection.toString(); 871 885 final rkey = atUri.rkey; 872 - final latest = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); 886 + final latest = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); 873 887 874 - final baseRecord = latest.data.value.isNotEmpty ? latest.data.value : originalRecord; 875 - final swapCid = latest.data.cid ?? currentCid; 888 + final latestValue = latest.data.value; 889 + final latestRecord = latestValue is Map ? Map<String, dynamic>.from(latestValue) : <String, dynamic>{}; 890 + final baseRecord = latestRecord.isNotEmpty ? latestRecord : originalRecord; 891 + final latestCid = latest.data.cid; 892 + final swapCid = latestCid is String && latestCid.isNotEmpty ? latestCid : currentCid; 876 893 final updatedRecord = Map<String, dynamic>.from(baseRecord); 877 894 updatedRecord['text'] = text; 878 895 if (facets.isNotEmpty) { ··· 932 949 ); 933 950 } 934 951 935 - final verified = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); 936 - 937 - final persistedText = verified.data.value['text']; 952 + final verified = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); 953 + final verifiedValue = verified.data.value; 954 + final persistedText = verifiedValue is Map ? verifiedValue['text'] : null; 938 955 if (persistedText is! String || persistedText != text) { 939 956 return const EditPostResult.failure( 940 957 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.', ··· 970 987 required String rkey, 971 988 }) async { 972 989 try { 973 - final response = await _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey); 974 - return (value: response.data.value, cid: response.data.cid); 990 + final response = await _getRecordFromRepo(repo: repo, collection: collection, rkey: rkey); 991 + final value = response.data.value; 992 + if (value is! Map) { 993 + return null; 994 + } 995 + final cid = response.data.cid; 996 + return (value: Map<String, dynamic>.from(value), cid: cid is String ? cid : null); 975 997 } on XRPCException catch (e, stackTrace) { 976 998 final errorCode = e.response.data.error; 977 999 if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { ··· 1005 1027 log.e('Failed to restore original record after edit failure', error: e, stackTrace: stackTrace); 1006 1028 return false; 1007 1029 } 1030 + } 1031 + 1032 + Future<dynamic> _getRecordFromRepo({required String repo, required String collection, required String rkey}) async { 1033 + final serviceHost = await _resolveRepoServiceHost(repo); 1034 + return _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey, $service: serviceHost); 1035 + } 1036 + 1037 + Future<String?> _resolveRepoServiceHost(String repo) async { 1038 + if (_isCurrentSessionRepo(repo)) { 1039 + return null; 1040 + } 1041 + 1042 + try { 1043 + final resolved = await _actorRepoResolver.resolve(repo); 1044 + return resolved.pdsHost; 1045 + } catch (error, stackTrace) { 1046 + log.w( 1047 + 'ComposeRepository: Failed to resolve non-self repo host for $repo; aborting foreign repo read', 1048 + error: error, 1049 + stackTrace: stackTrace, 1050 + ); 1051 + rethrow; 1052 + } 1053 + } 1054 + 1055 + bool _isCurrentSessionRepo(String repo) { 1056 + final normalizedRepo = repo.trim().toLowerCase(); 1057 + if (normalizedRepo.isEmpty) { 1058 + return false; 1059 + } 1060 + 1061 + final sessionDid = _bluesky.session?.did.trim().toLowerCase(); 1062 + if (sessionDid != null && sessionDid.isNotEmpty && normalizedRepo == sessionDid) { 1063 + return true; 1064 + } 1065 + 1066 + final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); 1067 + return oauthDid != null && oauthDid.isNotEmpty && normalizedRepo == oauthDid; 1008 1068 } 1009 1069 } 1010 1070
+105 -20
lib/features/devtools/cubit/dev_tools_cubit.dart
··· 11 11 import 'package:equatable/equatable.dart'; 12 12 import 'package:flutter_bloc/flutter_bloc.dart'; 13 13 import 'package:lazurite/core/logging/app_logger.dart'; 14 + import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 15 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 14 16 15 17 part 'dev_tools_state.dart'; 16 18 ··· 27 29 int? limit, 28 30 String? cursor, 29 31 bool? reverse, 32 + String? serviceHost, 30 33 }); 31 34 32 - Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}); 35 + Future<RepoGetRecordOutput> getRecord({ 36 + required String repo, 37 + required String collection, 38 + required String rkey, 39 + String? serviceHost, 40 + }); 33 41 } 34 42 35 43 final class AtprotoDevToolsRepository implements DevToolsRepository { ··· 73 81 int? limit, 74 82 String? cursor, 75 83 bool? reverse, 84 + String? serviceHost, 76 85 }) async { 77 86 final response = await _atproto.repo.listRecords( 78 87 repo: repo, ··· 80 89 limit: limit, 81 90 cursor: cursor, 82 91 reverse: reverse, 92 + $service: serviceHost, 83 93 ); 84 94 return response.data; 85 95 } ··· 89 99 required String repo, 90 100 required String collection, 91 101 required String rkey, 102 + String? serviceHost, 92 103 }) async { 93 - final response = await _atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey); 104 + final response = await _atproto.repo.getRecord( 105 + repo: repo, 106 + collection: collection, 107 + rkey: rkey, 108 + $service: serviceHost, 109 + ); 94 110 return response.data; 95 111 } 96 112 } 97 113 98 114 class DevToolsCubit extends Cubit<DevToolsState> { 99 - DevToolsCubit({ATProto? atproto, DevToolsRepository? repository}) 115 + DevToolsCubit({ATProto? atproto, DevToolsRepository? repository, ActorRepositoryServiceResolver? actorRepoResolver}) 100 116 : assert(atproto != null || repository != null, 'Provide either atproto or repository'), 101 117 _repository = repository ?? AtprotoDevToolsRepository(atproto: atproto!), 118 + _actorRepoResolver = actorRepoResolver ?? (atproto == null ? null : ActorRepositoryServiceResolver()), 102 119 super(const DevToolsState()); 103 120 104 121 static const _pageSize = 50; 105 122 static const _countPageSize = 100; 106 123 107 124 final DevToolsRepository _repository; 125 + final ActorRepositoryServiceResolver? _actorRepoResolver; 108 126 int _resolveRequestId = 0; 109 127 int _collectionRequestId = 0; 110 128 int _recordRequestId = 0; ··· 136 154 return; 137 155 } 138 156 139 - emit(_buildRepoState(repo: repo, did: identity.did, handle: identity.handle, status: DevToolsStatus.repoLoaded)); 140 - unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 157 + emit( 158 + _buildRepoState( 159 + repo: repo, 160 + did: identity.did, 161 + handle: identity.handle, 162 + repoServiceHost: identity.pdsHost, 163 + status: DevToolsStatus.repoLoaded, 164 + ), 165 + ); 166 + unawaited( 167 + _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 168 + ); 141 169 } catch (error, stackTrace) { 142 170 log.e('DevToolsCubit: Failed to resolve repo', error: error, stackTrace: stackTrace); 143 171 if (_isActiveResolveRequest(resolveRequestId)) { ··· 193 221 emit(state.copyWith(isCollectionLoading: true, isRecordLoading: false, errorMessage: null)); 194 222 195 223 try { 196 - final response = await _repository.listRecords(repo: state.did!, collection: collection, limit: _pageSize); 224 + final response = await _repository.listRecords( 225 + repo: state.did!, 226 + collection: collection, 227 + limit: _pageSize, 228 + serviceHost: state.repoServiceHost, 229 + ); 197 230 if (!_isActiveCollectionRequest(collectionRequestId)) { 198 231 return; 199 232 } ··· 230 263 collection: state.selectedCollection!, 231 264 cursor: state.recordsCursor, 232 265 limit: _pageSize, 266 + serviceHost: state.repoServiceHost, 233 267 ); 234 268 if (!_isActiveCollectionRequest(activeCollectionRequestId)) { 235 269 return; ··· 269 303 repo: state.did!, 270 304 collection: record.uri.collection.toString(), 271 305 rkey: record.uri.rkey, 306 + serviceHost: state.repoServiceHost, 272 307 ); 273 308 if (!_isActiveRecordRequest(recordRequestId)) { 274 309 return; ··· 389 424 final rkey = _rkeyFromAtUri(atUri); 390 425 391 426 if (collection == null) { 392 - emit(_buildRepoState(repo: repo, did: identity.did, handle: identity.handle, status: DevToolsStatus.repoLoaded)); 393 - unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 427 + emit( 428 + _buildRepoState( 429 + repo: repo, 430 + did: identity.did, 431 + handle: identity.handle, 432 + repoServiceHost: identity.pdsHost, 433 + status: DevToolsStatus.repoLoaded, 434 + ), 435 + ); 436 + unawaited( 437 + _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 438 + ); 394 439 return; 395 440 } 396 441 397 - final records = await _repository.listRecords(repo: identity.did, collection: collection, limit: _pageSize); 442 + final records = await _repository.listRecords( 443 + repo: identity.did, 444 + collection: collection, 445 + limit: _pageSize, 446 + serviceHost: identity.pdsHost, 447 + ); 398 448 if (!_isActiveResolveRequest(resolveRequestId)) { 399 449 return; 400 450 } ··· 405 455 repo: repo, 406 456 did: identity.did, 407 457 handle: identity.handle, 458 + repoServiceHost: identity.pdsHost, 408 459 status: DevToolsStatus.collectionLoaded, 409 460 selectedCollection: collection, 410 461 records: records.records, 411 462 recordsCursor: records.cursor, 412 463 ), 413 464 ); 414 - unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 465 + unawaited( 466 + _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 467 + ); 415 468 return; 416 469 } 417 470 418 - final record = await _repository.getRecord(repo: identity.did, collection: collection, rkey: rkey); 471 + final record = await _repository.getRecord( 472 + repo: identity.did, 473 + collection: collection, 474 + rkey: rkey, 475 + serviceHost: identity.pdsHost, 476 + ); 419 477 if (!_isActiveResolveRequest(resolveRequestId)) { 420 478 return; 421 479 } ··· 425 483 repo: repo, 426 484 did: identity.did, 427 485 handle: identity.handle, 486 + repoServiceHost: identity.pdsHost, 428 487 status: DevToolsStatus.recordLoaded, 429 488 selectedCollection: collection, 430 489 records: records.records, ··· 432 491 selectedRecord: RecordInfo(uri: record.uri.toString(), cid: record.cid, value: record.value), 433 492 ), 434 493 ); 435 - unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 494 + unawaited( 495 + _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 496 + ); 436 497 } 437 498 438 - Future<({String did, String? handle})> _resolveIdentity(String input) async { 499 + Future<({String did, String? handle, String pdsHost})> _resolveIdentity(String input) async { 439 500 final normalizedInput = _normalizeInputForResolve(input); 440 - if (normalizedInput.startsWith('did:')) { 441 - return (did: normalizedInput, handle: null); 501 + final resolver = _actorRepoResolver; 502 + if (resolver != null) { 503 + final resolution = await resolver.resolve(normalizedInput); 504 + return ( 505 + did: resolution.did, 506 + handle: normalizedInput.startsWith('did:') ? null : normalizedInput, 507 + pdsHost: resolution.pdsHost, 508 + ); 442 509 } 443 510 444 - final response = await _repository.resolveHandle(handle: normalizedInput); 445 - return (did: response.did, handle: normalizedInput); 511 + final did = normalizedInput.startsWith('did:') 512 + ? normalizedInput 513 + : (await _repository.resolveHandle(handle: normalizedInput)).did; 514 + final repo = await _repository.describeRepo(repo: did); 515 + final pdsHost = extractAtprotoPdsHostFromDidDoc(repo.didDoc); 516 + if (pdsHost == null || pdsHost.isEmpty) { 517 + throw StateError('Unable to resolve PDS host for repo: $did'); 518 + } 519 + return (did: did, handle: normalizedInput.startsWith('did:') ? null : normalizedInput, pdsHost: pdsHost); 446 520 } 447 521 448 522 AtUri _parseAtUri(String input) { ··· 475 549 required RepoDescribeRepoOutput repo, 476 550 required String did, 477 551 required String? handle, 552 + required String repoServiceHost, 478 553 required DevToolsStatus status, 479 554 String? selectedCollection, 480 555 List<RepoListRecordsRecord>? records, ··· 485 560 status: status, 486 561 did: did, 487 562 handle: handle ?? repo.handle, 563 + repoServiceHost: repoServiceHost, 488 564 repoHandle: repo.handle, 489 565 collections: repo.collections.map(CollectionSummary.new).toList(growable: false), 490 566 isCollectionCountsLoading: repo.collections.isNotEmpty, ··· 497 573 ); 498 574 } 499 575 500 - Future<void> _loadCollectionCounts({required int resolveRequestId, required String did}) async { 576 + Future<void> _loadCollectionCounts({ 577 + required int resolveRequestId, 578 + required String did, 579 + required String repoServiceHost, 580 + }) async { 501 581 if (state.collections.isEmpty) { 502 582 if (_isActiveResolveRequest(resolveRequestId)) { 503 583 emit(state.copyWith(isCollectionCountsLoading: false)); ··· 511 591 return; 512 592 } 513 593 514 - countsByCollection[collection.name] = await _countRecords(did: did, collection: collection.name); 594 + countsByCollection[collection.name] = await _countRecords( 595 + did: did, 596 + collection: collection.name, 597 + repoServiceHost: repoServiceHost, 598 + ); 515 599 if (!_isActiveResolveRequest(resolveRequestId)) { 516 600 return; 517 601 } ··· 525 609 } 526 610 } 527 611 528 - Future<int> _countRecords({required String did, required String collection}) async { 612 + Future<int> _countRecords({required String did, required String collection, required String repoServiceHost}) async { 529 613 var total = 0; 530 614 String? cursor; 531 615 ··· 535 619 collection: collection, 536 620 limit: _countPageSize, 537 621 cursor: cursor, 622 + serviceHost: repoServiceHost, 538 623 ); 539 624 total += response.records.length; 540 625 cursor = response.cursor;
+7
lib/features/devtools/cubit/dev_tools_state.dart
··· 44 44 const DevToolsState({ 45 45 this.status = DevToolsStatus.initial, 46 46 this.did, 47 + this.repoServiceHost, 47 48 this.handle, 48 49 this.repoHandle, 49 50 this.typeaheadActors = const [], ··· 61 62 62 63 final DevToolsStatus status; 63 64 final String? did; 65 + final String? repoServiceHost; 64 66 final String? handle; 65 67 final String? repoHandle; 66 68 final List<ProfileViewBasic> typeaheadActors; ··· 93 95 DevToolsState copyWith({ 94 96 DevToolsStatus? status, 95 97 Object? did = _devToolsStateNoChange, 98 + Object? repoServiceHost = _devToolsStateNoChange, 96 99 Object? handle = _devToolsStateNoChange, 97 100 Object? repoHandle = _devToolsStateNoChange, 98 101 List<ProfileViewBasic>? typeaheadActors, ··· 110 113 return DevToolsState( 111 114 status: status ?? this.status, 112 115 did: identical(did, _devToolsStateNoChange) ? this.did : did as String?, 116 + repoServiceHost: identical(repoServiceHost, _devToolsStateNoChange) 117 + ? this.repoServiceHost 118 + : repoServiceHost as String?, 113 119 handle: identical(handle, _devToolsStateNoChange) ? this.handle : handle as String?, 114 120 repoHandle: identical(repoHandle, _devToolsStateNoChange) ? this.repoHandle : repoHandle as String?, 115 121 typeaheadActors: typeaheadActors ?? this.typeaheadActors, ··· 134 140 List<Object?> get props => [ 135 141 status, 136 142 did, 143 + repoServiceHost, 137 144 handle, 138 145 repoHandle, 139 146 typeaheadActors,
+44 -20
lib/features/feed/data/liked_posts_repository.dart
··· 31 31 32 32 /// Syncs liked posts for [accountDid]. 33 33 /// 34 - /// Paginates through [bluesky.feed.getActorLikes] until it encounters an 35 - /// already-known URI or has fetched [_maxLikes] posts, whichever comes first. 36 - /// Upserts new entries and evicts oldest entries when count exceeds [_maxLikes]. 34 + /// Uses `app.bsky.feed.getActorLikes` for the signed-in account. 35 + /// Stops when the cursor is exhausted or [_maxLikes] posts are scanned. 37 36 Future<void> syncLikes(String accountDid) async { 38 37 String? cursor; 39 - var fetched = 0; 40 - var hitKnown = false; 38 + var scanned = 0; 41 39 42 - while (!hitKnown && fetched < _maxLikes) { 40 + while (scanned < _maxLikes) { 43 41 final response = await _bluesky.feed.getActorLikes( 44 42 actor: accountDid, 45 43 limit: _pageSize, 46 44 cursor: cursor, 47 - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getActorLikes'), 45 + $headers: _appViewContext.appBskyHeadersWithoutProxy(), 48 46 ); 49 47 50 48 final data = response.data; 51 - final posts = data.feed; 49 + final posts = (data.feed as List<dynamic>).whereType<FeedViewPost>().toList(growable: false); 52 50 53 51 if (posts.isEmpty) break; 52 + scanned += posts.length; 54 53 55 54 for (final FeedViewPost feedViewPost in posts) { 56 55 final postUri = feedViewPost.post.uri.toString(); 56 + final likedAt = _resolveLikedAt(feedViewPost); 57 + final postJson = jsonEncode(feedViewPost.toJson()); 57 58 58 59 final existing = await _database.getLikedPost(accountDid, postUri); 59 60 if (existing != null) { 60 - hitKnown = true; 61 - break; 61 + if (likedAt.isAfter(existing.likedAt)) { 62 + await _database.updateLikedPost(existing.id, postJson: postJson, likedAt: likedAt); 63 + _semanticIndexer?.queueIndexPost(postUri, postJson, accountDid, 'liked'); 64 + } 65 + continue; 62 66 } 63 67 64 - final likedAt = feedViewPost.reason == null 65 - ? DateTime.now() 66 - : _extractLikedAt(feedViewPost.reason!, DateTime.now()); 67 - 68 - final postJson = jsonEncode(feedViewPost.toJson()); 69 68 await _database.upsertLikedPost( 70 69 LikedPostsCompanion( 71 70 accountDid: Value(accountDid), ··· 75 74 ), 76 75 ); 77 76 _semanticIndexer?.queueIndexPost(postUri, postJson, accountDid, 'liked'); 78 - fetched++; 79 77 } 80 78 81 - if (hitKnown || data.cursor == null) break; 79 + if (data.cursor == null) break; 82 80 cursor = data.cursor; 83 81 } 84 82 ··· 96 94 return result; 97 95 } 98 96 99 - DateTime _extractLikedAt(dynamic reason, DateTime fallback) { 97 + DateTime _resolveLikedAt(FeedViewPost feedViewPost) { 98 + final fromReason = _extractReasonIndexedAt(feedViewPost.reason); 99 + if (fromReason != null) { 100 + return fromReason; 101 + } 102 + 103 + final indexedAt = (feedViewPost.post as dynamic).indexedAt; 104 + if (indexedAt is DateTime) { 105 + return indexedAt.toUtc(); 106 + } 107 + 108 + final createdAtRaw = feedViewPost.post.record['createdAt']; 109 + if (createdAtRaw is String) { 110 + final parsed = DateTime.tryParse(createdAtRaw); 111 + if (parsed != null) { 112 + return parsed.toUtc(); 113 + } 114 + } 115 + 116 + // Deterministic fallback for malformed/missing timestamps. 117 + return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); 118 + } 119 + 120 + DateTime? _extractReasonIndexedAt(dynamic reason) { 121 + if (reason == null) { 122 + return null; 123 + } 100 124 try { 101 125 final map = reason is Map ? reason : (reason as dynamic).toJson(); 102 126 final indexedAt = map['indexedAt'] as String?; 103 127 if (indexedAt != null) { 104 - return DateTime.parse(indexedAt); 128 + return DateTime.parse(indexedAt).toUtc(); 105 129 } 106 130 } catch (_) {} 107 - return fallback; 131 + return null; 108 132 } 109 133 }
+70 -9
lib/features/feed/presentation/saved_posts_screen.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 3 4 import 'package:bluesky/app_bsky_feed_defs.dart'; ··· 278 279 bool _isLoading = true; 279 280 bool _isSyncing = false; 280 281 String? _error; 282 + String? _syncWarning; 281 283 LikedPostsRepository? _repository; 282 284 283 285 @override ··· 297 299 return; 298 300 } 299 301 300 - await _syncAndReload(initial: true); 302 + await _loadCachedLikes(); 303 + unawaited(_syncAndReload()); 304 + } 305 + 306 + Future<void> _loadCachedLikes() async { 307 + final repository = _repository; 308 + if (repository == null) { 309 + return; 310 + } 311 + final accountDid = context.read<String>(); 312 + try { 313 + final likedPosts = await repository.getLikedPosts(accountDid, limit: 200); 314 + if (!mounted) { 315 + return; 316 + } 317 + setState(() { 318 + _likedPosts = likedPosts; 319 + _isLoading = false; 320 + _error = null; 321 + }); 322 + } catch (e) { 323 + if (!mounted) { 324 + return; 325 + } 326 + setState(() { 327 + _isLoading = false; 328 + _error = 'Failed to load liked posts: $e'; 329 + }); 330 + } 301 331 } 302 332 303 - Future<void> _syncAndReload({bool initial = false}) async { 333 + Future<void> _syncAndReload() async { 304 334 final repository = _repository; 305 335 if (repository == null) { 306 336 return; ··· 309 339 310 340 if (mounted) { 311 341 setState(() { 312 - _isLoading = initial; 313 - _isSyncing = !initial; 314 - _error = null; 342 + _isSyncing = true; 343 + _syncWarning = null; 344 + if (_likedPosts.isEmpty) { 345 + _error = null; 346 + } 315 347 }); 316 348 } 317 349 ··· 325 357 _likedPosts = likedPosts; 326 358 _isLoading = false; 327 359 _isSyncing = false; 360 + _error = null; 361 + _syncWarning = null; 328 362 }); 329 363 } catch (e) { 330 364 if (!mounted) { 331 365 return; 332 366 } 367 + if (_likedPosts.isNotEmpty) { 368 + setState(() { 369 + _isLoading = false; 370 + _isSyncing = false; 371 + _syncWarning = 'Failed to refresh liked posts: $e'; 372 + }); 373 + return; 374 + } 333 375 setState(() { 334 376 _isLoading = false; 335 377 _isSyncing = false; ··· 345 387 } 346 388 final accountDid = context.read<String>(); 347 389 await repository.removeLike(accountDid, entry.postUri); 390 + await _loadCachedLikes(); 348 391 await _syncAndReload(); 349 392 } 350 393 ··· 354 397 return const LoadingState(); 355 398 } 356 399 357 - if (_error != null) { 400 + if (_error != null && _likedPosts.isEmpty) { 358 401 return ErrorState(title: 'Failed to load liked posts', message: _error!, onRetry: () => _syncAndReload()); 359 402 } 360 403 ··· 379 422 AnimatedRefreshIndicator( 380 423 onRefresh: _syncAndReload, 381 424 child: ListView.builder( 382 - itemCount: _likedPosts.length, 425 + itemCount: _likedPosts.length + (_syncWarning == null ? 0 : 1), 383 426 itemBuilder: (context, index) { 384 - final likedPost = _likedPosts[index]; 427 + if (_syncWarning != null && index == 0) { 428 + return Padding( 429 + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), 430 + child: Material( 431 + color: context.colorScheme.errorContainer, 432 + borderRadius: BorderRadius.circular(12), 433 + child: Padding( 434 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), 435 + child: Text( 436 + _syncWarning!, 437 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onErrorContainer), 438 + ), 439 + ), 440 + ), 441 + ); 442 + } 443 + 444 + final likedPost = _likedPosts[_syncWarning == null ? index : index - 1]; 445 + final listIndex = _syncWarning == null ? index : index - 1; 385 446 return StaggeredEntrance( 386 447 itemKey: likedPost.postUri, 387 - index: index, 448 + index: listIndex, 388 449 seenKeys: _seenPostUris, 389 450 child: _LikedPostCard(likedPost: likedPost, onRemove: () => _removeLike(likedPost)), 390 451 );
+39
lib/features/profile/data/follow_audit_repository.dart
··· 68 68 final AppViewRequestContext _appViewContext; 69 69 70 70 Future<List<FollowRecord>> fetchAllFollows(String did, {void Function(int fetched)? onProgress}) async { 71 + _assertCurrentSessionRepoAccess(did: did, operation: 'fetchAllFollows'); 71 72 final records = <FollowRecord>[]; 72 73 String? cursor; 73 74 ··· 229 230 } 230 231 231 232 Future<int> batchUnfollow(List<ClassifiedFollow> selected, String ownDid) async { 233 + _assertCurrentSessionRepoAccess(did: ownDid, operation: 'batchUnfollow'); 232 234 if (selected.isEmpty) return 0; 233 235 234 236 final rkeys = selected.map((f) => f.record.rkey).toList(); ··· 339 341 case FollowStatus.selfFollow: 340 342 return 'Self-follow'; 341 343 } 344 + } 345 + 346 + void _assertCurrentSessionRepoAccess({required String did, required String operation}) { 347 + final normalizedDid = did.trim().toLowerCase(); 348 + if (normalizedDid.isEmpty) { 349 + throw ArgumentError.value(did, 'did', 'DID must not be empty'); 350 + } 351 + 352 + final sessionDid = _currentSessionDid(); 353 + if (sessionDid == null) { 354 + // Test doubles and unauthenticated contexts may not expose session shape. 355 + return; 356 + } 357 + 358 + if (normalizedDid != sessionDid) { 359 + throw StateError( 360 + 'FollowAuditRepository.$operation supports only current-session repo access: did=$normalizedDid sessionDid=$sessionDid', 361 + ); 362 + } 363 + } 364 + 365 + String? _currentSessionDid() { 366 + try { 367 + final sessionDid = (_bluesky.session?.did as String?)?.trim().toLowerCase(); 368 + if (sessionDid != null && sessionDid.isNotEmpty) { 369 + return sessionDid; 370 + } 371 + } catch (_) {} 372 + 373 + try { 374 + final oauthDid = (_bluesky.oAuthSession?.sub as String?)?.trim().toLowerCase(); 375 + if (oauthDid != null && oauthDid.isNotEmpty) { 376 + return oauthDid; 377 + } 378 + } catch (_) {} 379 + 380 + return null; 342 381 } 343 382 } 344 383
+39
lib/features/profile/data/profile_context_repository.dart
··· 97 97 98 98 /// Returns the total number of accounts that [did] is blocking. 99 99 Future<int> getBlockingCount(String did) async { 100 + _assertCurrentSessionRepoAccess(did: did, operation: 'getBlockingCount'); 100 101 var total = 0; 101 102 String? cursor; 102 103 ··· 120 121 /// [total] reflects the number of profiles hydrated in this page. 121 122 Future<({List<ProfileView> profiles, List<UnavailableProfileRef> unavailable, String? cursor, int total})> 122 123 getBlockingProfiles(String did, {String? cursor}) async { 124 + _assertCurrentSessionRepoAccess(did: did, operation: 'getBlockingProfiles'); 123 125 final response = await _bluesky.atproto.repo.listRecords( 124 126 repo: did, 125 127 collection: 'app.bsky.graph.block', ··· 329 331 final trimmed = did.trim(); 330 332 if (trimmed.isEmpty) return null; 331 333 return trimmed; 334 + } 335 + 336 + void _assertCurrentSessionRepoAccess({required String did, required String operation}) { 337 + final normalizedDid = _normalizeDid(did)?.toLowerCase(); 338 + if (normalizedDid == null) { 339 + throw ArgumentError.value(did, 'did', 'DID must not be empty'); 340 + } 341 + 342 + final sessionDid = _currentSessionDid(); 343 + if (sessionDid == null) { 344 + // Test doubles and unauthenticated contexts may not expose session shape. 345 + return; 346 + } 347 + 348 + if (normalizedDid != sessionDid) { 349 + throw StateError( 350 + 'ProfileContextRepository.$operation supports only current-session repo reads: did=$normalizedDid sessionDid=$sessionDid', 351 + ); 352 + } 353 + } 354 + 355 + String? _currentSessionDid() { 356 + try { 357 + final sessionDid = (_bluesky.session?.did as String?)?.trim().toLowerCase(); 358 + if (sessionDid != null && sessionDid.isNotEmpty) { 359 + return sessionDid; 360 + } 361 + } catch (_) {} 362 + 363 + try { 364 + final oauthDid = (_bluesky.oAuthSession?.sub as String?)?.trim().toLowerCase(); 365 + if (oauthDid != null && oauthDid.isNotEmpty) { 366 + return oauthDid; 367 + } 368 + } catch (_) {} 369 + 370 + return null; 332 371 } 333 372 334 373 String _publicProfileFailureReason(Object error) {
+237 -14
lib/features/profile/data/profile_repository.dart
··· 7 7 import 'package:bluesky/bluesky.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 + import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 11 + import 'package:lazurite/core/network/app_view_provider.dart'; 10 12 import 'package:lazurite/core/network/app_view_request_context.dart'; 11 13 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 14 import 'package:lazurite/features/moderation/data/moderation_service.dart'; ··· 16 18 required AppDatabase database, 17 19 required dynamic bluesky, 18 20 ModerationService? moderationService, 21 + ActorRepositoryServiceResolver? actorRepositoryServiceResolver, 19 22 String? appViewProvider, 20 23 String Function()? appViewProviderResolver, 21 24 }) : _database = database, 22 25 _bluesky = bluesky, 23 26 _moderationService = moderationService, 27 + _actorRepoResolver = actorRepositoryServiceResolver ?? _createActorRepositoryServiceResolver(), 24 28 _appViewContext = AppViewRequestContext( 25 29 appViewProvider: appViewProvider, 26 30 appViewProviderResolver: appViewProviderResolver, ··· 29 33 final AppDatabase _database; 30 34 final dynamic _bluesky; 31 35 final ModerationService? _moderationService; 36 + final ActorRepositoryServiceResolver? _actorRepoResolver; 32 37 final AppViewRequestContext _appViewContext; 38 + static const int _maxProfilesBatchSize = 25; 39 + static const int _maxPostsHydrationBatchSize = 25; 40 + 41 + static ActorRepositoryServiceResolver? _createActorRepositoryServiceResolver() { 42 + try { 43 + return ActorRepositoryServiceResolver(); 44 + } catch (_) { 45 + return null; 46 + } 47 + } 33 48 34 49 Future<ProfileViewDetailed> getProfile(String actor) async { 35 50 log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); ··· 77 92 'app.bsky.actor.getProfiles', 78 93 await _moderationService?.headersForRequest(), 79 94 ); 95 + final normalizedActors = actors.where((actor) => actor.trim().isNotEmpty).toList(growable: false); 96 + if (normalizedActors.isEmpty) { 97 + return const []; 98 + } 99 + 80 100 log.i( 81 - 'ProfileRepository: getProfiles request actors=${actors.length} atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 101 + 'ProfileRepository: getProfiles request actors=${normalizedActors.length} batchSize=$_maxProfilesBatchSize atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 82 102 ); 83 - final response = await _bluesky.actor.getProfiles(actors: actors, $headers: headers); 84 - final profiles = response.data.profiles 85 - .where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)) 86 - .toList(); 103 + 104 + final profiles = <ProfileView>[]; 105 + for (var i = 0; i < normalizedActors.length; i += _maxProfilesBatchSize) { 106 + final batch = normalizedActors.sublist(i, (i + _maxProfilesBatchSize).clamp(0, normalizedActors.length)); 107 + final response = await _bluesky.actor.getProfiles(actors: batch, $headers: headers); 108 + profiles.addAll( 109 + response.data.profiles.where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)), 110 + ); 111 + } 112 + 87 113 log.i('ProfileRepository: Loaded ${profiles.length} profiles'); 88 114 return profiles; 89 115 } ··· 103 129 } 104 130 105 131 Future<ProfileActorLikesResult> getActorLikes({required String actor, String? cursor, int limit = 50}) async { 106 - final headers = _appViewContext.appBskyHeadersForEndpoint( 107 - 'app.bsky.feed.getActorLikes', 132 + // Likes transport matrix: 133 + // - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context 134 + // (PDS-routed, read-after-write behavior for the current account). 135 + // - Non-self liked tab: actor repo scan on actor PDS via 136 + // com.atproto.repo.listRecords(app.bsky.feed.like), then hydrate subjects 137 + // on AppView via app.bsky.feed.getPosts. 138 + // Never route non-self repo reads through the viewer PDS. 139 + if (_isCurrentSessionActor(actor)) { 140 + final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 141 + log.i( 142 + 'ProfileRepository: likes self path actor=$actor endpoint=app.bsky.feed.getActorLikes host=current-session-pds', 143 + ); 144 + final response = await _bluesky.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers); 145 + final feed = (response.data.feed as List<dynamic>).whereType<FeedViewPost>().toList(growable: false); 146 + final moderationService = _moderationService; 147 + final posts = moderationService == null 148 + ? feed 149 + : feed.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 150 + return ProfileActorLikesResult( 151 + entries: posts 152 + .map( 153 + (post) => 154 + ProfileActorLikeEntry.available(feedViewPost: post, likedAt: _extractLikedAtFromReason(post.reason)), 155 + ) 156 + .toList(growable: false), 157 + cursor: response.data.cursor, 158 + ); 159 + } 160 + 161 + final resolved = await _resolveActorRepositoryService(actor); 162 + log.i( 163 + 'ProfileRepository: likes non-self list path actor=$actor did=${resolved.did} endpoint=com.atproto.repo.listRecords host=${resolved.pdsHost}', 164 + ); 165 + final recordsResponse = await _bluesky.atproto.repo.listRecords( 166 + repo: resolved.did, 167 + collection: 'app.bsky.feed.like', 168 + limit: limit.clamp(1, 100), 169 + cursor: cursor, 170 + reverse: false, 171 + $service: resolved.pdsHost, 172 + ); 173 + final likeRecords = _extractLikeRecords(recordsResponse.data.records as List<dynamic>); 174 + if (likeRecords.isEmpty) { 175 + return ProfileActorLikesResult(entries: const [], cursor: recordsResponse.data.cursor); 176 + } 177 + 178 + final appViewHost = AppViewProviders.bluesky.publicBaseUrl.host; 179 + log.i( 180 + 'ProfileRepository: likes non-self hydrate path actor=$actor did=${resolved.did} endpoint=app.bsky.feed.getPosts host=$appViewHost', 181 + ); 182 + final hydrationHeaders = _appViewContext.appBskyHeadersForEndpoint( 183 + 'app.bsky.feed.getPosts', 108 184 await _moderationService?.headersForRequest(), 109 185 ); 110 - final response = await _bluesky.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers); 111 186 final moderationService = _moderationService; 112 - final posts = moderationService == null 113 - ? response.data.feed 114 - : response.data.feed.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 115 - return ProfileActorLikesResult(posts: posts, cursor: response.data.cursor); 187 + final postsByUri = <String, PostView>{}; 188 + final subjectUris = likeRecords.map((record) => atp_core.AtUri.parse(record.subjectUri)).toList(growable: false); 189 + for (var i = 0; i < subjectUris.length; i += _maxPostsHydrationBatchSize) { 190 + final batch = subjectUris.sublist(i, (i + _maxPostsHydrationBatchSize).clamp(0, subjectUris.length)); 191 + final response = await _bluesky.feed.getPosts(uris: batch, $service: appViewHost, $headers: hydrationHeaders); 192 + for (final post in response.data.posts) { 193 + postsByUri[post.uri.toString()] = post; 194 + } 195 + } 196 + 197 + final entries = <ProfileActorLikeEntry>[]; 198 + for (final record in likeRecords) { 199 + final post = postsByUri[record.subjectUri]; 200 + if (post != null) { 201 + final feedViewPost = FeedViewPost(post: post); 202 + if (moderationService != null && moderationService.shouldFilterFeedViewPostInList(feedViewPost)) { 203 + continue; 204 + } 205 + entries.add( 206 + ProfileActorLikeEntry.available( 207 + feedViewPost: FeedViewPost(post: post), 208 + likedAt: record.createdAt, 209 + ), 210 + ); 211 + } else { 212 + entries.add(ProfileActorLikeEntry.unavailable(subjectUri: record.subjectUri, likedAt: record.createdAt)); 213 + } 214 + } 215 + 216 + return ProfileActorLikesResult(entries: entries, cursor: recordsResponse.data.cursor); 116 217 } 117 218 118 219 Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { ··· 203 304 } 204 305 return null; 205 306 } 307 + 308 + bool _isCurrentSessionActor(String actor) { 309 + final normalizedActor = actor.trim().toLowerCase(); 310 + if (normalizedActor.isEmpty) { 311 + return false; 312 + } 313 + 314 + final bluesky = _bluesky; 315 + if (bluesky is Bluesky) { 316 + final session = bluesky.session; 317 + final sessionDid = session?.did.trim().toLowerCase(); 318 + final sessionHandle = session?.handle.trim().toLowerCase(); 319 + if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { 320 + return true; 321 + } 322 + 323 + final oauthDid = bluesky.oAuthSession?.sub.trim().toLowerCase(); 324 + return normalizedActor == oauthDid; 325 + } 326 + 327 + try { 328 + final session = bluesky.session; 329 + final sessionDid = (session?.did as String?)?.trim().toLowerCase(); 330 + final sessionHandle = (session?.handle as String?)?.trim().toLowerCase(); 331 + if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { 332 + return true; 333 + } 334 + } catch (_) {} 335 + 336 + try { 337 + final oauthSession = bluesky.oAuthSession; 338 + final oauthDid = (oauthSession?.sub as String?)?.trim().toLowerCase(); 339 + if (normalizedActor == oauthDid) { 340 + return true; 341 + } 342 + } catch (_) { 343 + // Ignore non-standard test doubles/wrappers missing OAuth shape. 344 + } 345 + return false; 346 + } 347 + 348 + Future<ActorRepositoryServiceResolution> _resolveActorRepositoryService(String actor) async { 349 + final resolver = _actorRepoResolver; 350 + if (resolver == null) { 351 + throw StateError('Actor repository resolver is unavailable in this profile repository context.'); 352 + } 353 + return resolver.resolve(actor); 354 + } 355 + 356 + List<_LikeRecord> _extractLikeRecords(List<dynamic> rawRecords) { 357 + final records = <_LikeRecord>[]; 358 + for (final raw in rawRecords) { 359 + final value = (raw as dynamic).value; 360 + if (value is! Map) { 361 + continue; 362 + } 363 + final subject = value['subject']; 364 + if (subject is! Map) { 365 + continue; 366 + } 367 + final subjectUri = subject['uri']; 368 + if (subjectUri is String && subjectUri.isNotEmpty) { 369 + final createdAtRaw = value['createdAt']; 370 + final createdAt = createdAtRaw is String ? DateTime.tryParse(createdAtRaw) : null; 371 + records.add(_LikeRecord(subjectUri: subjectUri, createdAt: createdAt)); 372 + } 373 + } 374 + return records; 375 + } 376 + 377 + DateTime? _extractLikedAtFromReason(dynamic reason) { 378 + if (reason == null) { 379 + return null; 380 + } 381 + try { 382 + final map = reason is Map ? reason : (reason as dynamic).toJson(); 383 + final indexedAt = map['indexedAt'] as String?; 384 + return indexedAt == null ? null : DateTime.tryParse(indexedAt); 385 + } catch (_) { 386 + return null; 387 + } 388 + } 206 389 } 207 390 208 391 class ProfileActorLikesResult { 209 - const ProfileActorLikesResult({required this.posts, this.cursor}); 392 + const ProfileActorLikesResult({required this.entries, this.cursor}); 210 393 211 - final List<FeedViewPost> posts; 394 + final List<ProfileActorLikeEntry> entries; 212 395 final String? cursor; 396 + 397 + List<FeedViewPost> get posts => 398 + entries.where((entry) => entry.isAvailable).map((entry) => entry.feedViewPost!).toList(growable: false); 399 + 400 + List<String> get unavailableSubjectUris => 401 + entries.where((entry) => !entry.isAvailable).map((entry) => entry.subjectUri!).toList(growable: false); 402 + } 403 + 404 + class ProfileActorLikeEntry { 405 + const ProfileActorLikeEntry._({ 406 + required this.likedAt, 407 + required this.feedViewPost, 408 + required this.subjectUri, 409 + required this.unavailableReason, 410 + }); 411 + 412 + const ProfileActorLikeEntry.available({required FeedViewPost feedViewPost, required DateTime? likedAt}) 413 + : this._(likedAt: likedAt, feedViewPost: feedViewPost, subjectUri: null, unavailableReason: null); 414 + 415 + const ProfileActorLikeEntry.unavailable({required String subjectUri, required DateTime? likedAt}) 416 + : this._( 417 + likedAt: likedAt, 418 + feedViewPost: null, 419 + subjectUri: subjectUri, 420 + unavailableReason: 'Post unavailable or failed to hydrate', 421 + ); 422 + 423 + final DateTime? likedAt; 424 + final FeedViewPost? feedViewPost; 425 + final String? subjectUri; 426 + final String? unavailableReason; 427 + 428 + bool get isAvailable => feedViewPost != null; 429 + } 430 + 431 + class _LikeRecord { 432 + const _LikeRecord({required this.subjectUri, required this.createdAt}); 433 + 434 + final String subjectUri; 435 + final DateTime? createdAt; 213 436 }
+43 -11
lib/features/profile/presentation/profile_screen.dart
··· 1490 1490 } 1491 1491 1492 1492 class _ProfileLikedPostsPaneState extends State<_ProfileLikedPostsPane> { 1493 - List<FeedViewPost> _posts = const []; 1493 + List<ProfileActorLikeEntry> _entries = const []; 1494 1494 String? _cursor; 1495 1495 bool _isLoading = true; 1496 1496 bool _isLoadingMore = false; ··· 1515 1515 setState(() { 1516 1516 _isLoading = true; 1517 1517 _error = null; 1518 - _posts = const []; 1518 + _entries = const []; 1519 1519 _cursor = null; 1520 1520 _hasMore = true; 1521 1521 }); ··· 1524 1524 final page = await widget.profileRepository.getActorLikes(actor: widget.actor, limit: 50); 1525 1525 if (!mounted) return; 1526 1526 setState(() { 1527 - _posts = page.posts; 1527 + _entries = page.entries; 1528 1528 _cursor = page.cursor; 1529 1529 _hasMore = page.cursor != null; 1530 1530 _isLoading = false; ··· 1552 1552 final page = await widget.profileRepository.getActorLikes(actor: widget.actor, cursor: _cursor, limit: 50); 1553 1553 if (!mounted) return; 1554 1554 setState(() { 1555 - _posts = [..._posts, ...page.posts]; 1555 + _entries = [..._entries, ...page.entries]; 1556 1556 _cursor = page.cursor; 1557 1557 _hasMore = page.cursor != null; 1558 1558 _isLoadingMore = false; ··· 1582 1582 ); 1583 1583 } 1584 1584 1585 - if (_posts.isEmpty) { 1585 + if (_entries.isEmpty) { 1586 1586 return const Center(child: Text('No liked posts yet')); 1587 1587 } 1588 1588 ··· 1598 1598 }, 1599 1599 child: ListView.builder( 1600 1600 key: const PageStorageKey<String>('profile-liked-posts-list'), 1601 - itemCount: _posts.length + (_isLoadingMore ? 1 : 0), 1601 + itemCount: _entries.length + (_isLoadingMore ? 1 : 0), 1602 1602 itemBuilder: (context, index) { 1603 - if (index >= _posts.length) { 1603 + if (index >= _entries.length) { 1604 1604 return const Padding( 1605 1605 padding: EdgeInsets.all(16), 1606 1606 child: Center(child: CircularProgressIndicator()), 1607 1607 ); 1608 1608 } 1609 - return PostCardWithActions( 1610 - feedViewPost: _posts[index], 1611 - accountDid: accountDid, 1612 - moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1609 + final entry = _entries[index]; 1610 + if (entry.feedViewPost != null) { 1611 + return PostCardWithActions( 1612 + feedViewPost: entry.feedViewPost!, 1613 + accountDid: accountDid, 1614 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1615 + ); 1616 + } 1617 + 1618 + return _UnavailableLikedPostCard( 1619 + subjectUri: entry.subjectUri ?? '', 1620 + reason: entry.unavailableReason ?? 'Post unavailable', 1613 1621 ); 1614 1622 }, 1623 + ), 1624 + ), 1625 + ); 1626 + } 1627 + } 1628 + 1629 + class _UnavailableLikedPostCard extends StatelessWidget { 1630 + const _UnavailableLikedPostCard({required this.subjectUri, required this.reason}); 1631 + 1632 + final String subjectUri; 1633 + final String reason; 1634 + 1635 + @override 1636 + Widget build(BuildContext context) { 1637 + return Card( 1638 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 1639 + child: ListTile( 1640 + leading: const Icon(Icons.hide_source_outlined), 1641 + title: const Text('Unavailable liked post'), 1642 + subtitle: Text(reason), 1643 + trailing: IconButton( 1644 + icon: const Icon(Icons.open_in_new), 1645 + onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(subjectUri)}'), 1646 + tooltip: 'Open', 1615 1647 ), 1616 1648 ), 1617 1649 );
+4 -22
lib/features/search/cubit/semantic_search_cubit.dart
··· 9 9 10 10 export 'package:lazurite/features/search/data/search_scope.dart'; 11 11 12 - enum SemanticSearchStatus { initial, searching, loaded, error, unavailable } 12 + enum SemanticSearchStatus { initial, searching, loaded, error } 13 13 14 14 class SemanticSearchState extends Equatable { 15 15 const SemanticSearchState({ ··· 49 49 /// Exposes a [search] method with a 500 ms debounce, a [setScope] method to 50 50 /// filter results by source, and a [clearResults] method to reset state. 51 51 /// 52 - /// The initial state is [SemanticSearchStatus.unavailable] when the 53 - /// [EmbeddingService] is not available (e.g. model failed to load). 54 52 class SemanticSearchCubit extends Cubit<SemanticSearchState> { 55 53 SemanticSearchCubit({ 56 54 required SemanticSearchRepository repository, ··· 63 61 _accountDid = accountDid, 64 62 _maxResults = maxResults, 65 63 _debounceDuration = debounceDuration, 66 - super( 67 - embeddingService.isAvailable 68 - ? const SemanticSearchState() 69 - : const SemanticSearchState(status: SemanticSearchStatus.unavailable), 70 - ) { 71 - if (!embeddingService.isAvailable) { 72 - unawaited(_recoverAvailability()); 73 - } 74 - } 64 + super(const SemanticSearchState()); 75 65 76 66 final SemanticSearchRepository _repository; 77 67 final EmbeddingService _embeddingService; ··· 105 95 if (await _ensureAvailable()) { 106 96 await _doSearch(state.query); 107 97 } else if (!isClosed) { 108 - emit(state.copyWith(status: SemanticSearchStatus.unavailable)); 98 + emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); 109 99 } 110 100 } 111 101 } ··· 116 106 emit(SemanticSearchState(scope: state.scope)); 117 107 } 118 108 119 - Future<void> _recoverAvailability() async { 120 - if (await _ensureAvailable()) { 121 - if (!isClosed && state.status == SemanticSearchStatus.unavailable) { 122 - emit(SemanticSearchState(scope: state.scope)); 123 - } 124 - } 125 - } 126 - 127 109 Future<bool> _ensureAvailable() async { 128 110 if (_embeddingService.isAvailable) return true; 129 111 await _embeddingService.initialize(); ··· 134 116 if (await _ensureAvailable()) { 135 117 await _doSearch(query); 136 118 } else if (!isClosed) { 137 - emit(state.copyWith(status: SemanticSearchStatus.unavailable)); 119 + emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); 138 120 } 139 121 } 140 122
+1
lib/features/search/data/search_repository.dart
··· 197 197 198 198 final hydrated = await _bluesky.feed.getPosts( 199 199 uris: atUris, 200 + $service: _appViewContext.publicServiceHost(), 200 201 $headers: _appViewContext.appBskyHeadersForEndpoint( 201 202 'app.bsky.feed.getPosts', 202 203 await _moderationService?.headersForRequest(),
+64 -142
lib/features/search/presentation/semantic_search_tab.dart
··· 53 53 Widget build(BuildContext context) { 54 54 return BlocBuilder<SemanticSearchCubit, SemanticSearchState>( 55 55 builder: (context, state) { 56 - if (state.status == SemanticSearchStatus.unavailable) { 57 - return const _UnavailableView(); 58 - } 59 - 60 56 return BlocBuilder<SettingsCubit, SettingsState>( 61 - builder: (context, settingsState) { 57 + builder: (context, _) { 62 58 return Column( 63 59 children: [ 64 60 BlocBuilder<SemanticIndexCubit, SemanticIndexState>( ··· 66 62 return _IndexControls(indexState: indexState); 67 63 }, 68 64 ), 69 - if (!settingsState.semanticSearchEnabled) ...[ 70 - const Divider(height: 1), 71 - const Expanded(child: _DisabledView()), 72 - ] else ...[ 73 - _SearchBar( 74 - controller: _controller, 75 - onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 76 - onClear: () { 77 - _controller.clear(); 78 - context.read<SemanticSearchCubit>().clearResults(); 79 - }, 80 - ), 81 - _ScopeChips( 82 - selected: state.scope, 83 - onSelected: (scope) async { 84 - await context.read<SemanticSearchCubit>().setScope(scope); 85 - if (context.mounted) { 86 - unawaited(context.read<SettingsCubit>().setSearchScope(scope)); 87 - } 88 - }, 89 - ), 90 - const Divider(height: 1), 91 - Expanded(child: _ResultsView(state: state)), 92 - ], 65 + _SearchBar( 66 + controller: _controller, 67 + onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 68 + onClear: () { 69 + _controller.clear(); 70 + context.read<SemanticSearchCubit>().clearResults(); 71 + }, 72 + ), 73 + _ScopeChips( 74 + selected: state.scope, 75 + onSelected: (scope) async { 76 + await context.read<SemanticSearchCubit>().setScope(scope); 77 + if (context.mounted) { 78 + unawaited(context.read<SettingsCubit>().setSearchScope(scope)); 79 + } 80 + }, 81 + ), 82 + const Divider(height: 1), 83 + Expanded(child: _ResultsView(state: state)), 93 84 ], 94 85 ); 95 86 }, ··· 203 194 children: [ 204 195 Text('Semantic settings', style: context.textTheme.titleLarge), 205 196 const SizedBox(height: 12), 206 - SwitchListTile.adaptive( 207 - value: settingsState.semanticSearchEnabled, 208 - onChanged: (value) => unawaited(context.read<SettingsCubit>().setSemanticSearchEnabled(value)), 209 - title: const Text('Enable semantic search'), 210 - subtitle: const Text('Search this account\'s saved and liked posts by meaning'), 211 - contentPadding: EdgeInsets.zero, 197 + Text( 198 + 'Semantic search is always enabled for saved and liked posts.', 199 + style: context.textTheme.bodyMedium, 212 200 ), 213 - if (settingsState.semanticSearchEnabled) ...[ 214 - const SizedBox(height: 8), 215 - Text('Default scope', style: context.textTheme.titleSmall), 216 - const SizedBox(height: 8), 217 - DropdownButtonFormField<SearchScope>( 218 - initialValue: settingsState.searchScope, 219 - decoration: const InputDecoration(border: OutlineInputBorder()), 220 - items: const [ 221 - DropdownMenuItem(value: SearchScope.both, child: Text('Saved + Liked')), 222 - DropdownMenuItem(value: SearchScope.saved, child: Text('Saved only')), 223 - DropdownMenuItem(value: SearchScope.liked, child: Text('Liked only')), 224 - ], 225 - onChanged: (scope) async { 226 - if (scope == null) return; 227 - await context.read<SettingsCubit>().setSearchScope(scope); 228 - if (context.mounted) { 229 - await context.read<SemanticSearchCubit>().setScope(scope); 230 - } 231 - }, 232 - ), 233 - const SizedBox(height: 16), 234 - Row( 235 - children: [ 236 - Text('Max results', style: context.textTheme.titleSmall), 237 - const Spacer(), 238 - Text( 239 - '${settingsState.semanticSearchMaxResults}', 240 - style: context.textTheme.titleSmall?.copyWith(fontFamily: 'JetBrains Mono'), 241 - ), 242 - ], 243 - ), 244 - Slider( 245 - value: settingsState.semanticSearchMaxResults.toDouble(), 246 - min: 10, 247 - max: 50, 248 - divisions: 8, 249 - onChanged: (v) { 250 - final value = v.round(); 251 - context.read<SettingsCubit>().setSemanticSearchMaxResults(value); 252 - context.read<SemanticSearchCubit>().setMaxResults(value); 253 - }, 254 - ), 255 - ], 201 + const SizedBox(height: 12), 202 + Text('Default scope', style: context.textTheme.titleSmall), 203 + const SizedBox(height: 8), 204 + DropdownButtonFormField<SearchScope>( 205 + initialValue: settingsState.searchScope, 206 + decoration: const InputDecoration(border: OutlineInputBorder()), 207 + items: const [ 208 + DropdownMenuItem(value: SearchScope.both, child: Text('Saved + Liked')), 209 + DropdownMenuItem(value: SearchScope.saved, child: Text('Saved only')), 210 + DropdownMenuItem(value: SearchScope.liked, child: Text('Liked only')), 211 + ], 212 + onChanged: (scope) async { 213 + if (scope == null) return; 214 + await context.read<SettingsCubit>().setSearchScope(scope); 215 + if (context.mounted) { 216 + await context.read<SemanticSearchCubit>().setScope(scope); 217 + } 218 + }, 219 + ), 220 + const SizedBox(height: 16), 221 + Row( 222 + children: [ 223 + Text('Max results', style: context.textTheme.titleSmall), 224 + const Spacer(), 225 + Text( 226 + '${settingsState.semanticSearchMaxResults}', 227 + style: context.textTheme.titleSmall?.copyWith(fontFamily: 'JetBrains Mono'), 228 + ), 229 + ], 230 + ), 231 + Slider( 232 + value: settingsState.semanticSearchMaxResults.toDouble(), 233 + min: 10, 234 + max: 50, 235 + divisions: 8, 236 + onChanged: (v) { 237 + final value = v.round(); 238 + context.read<SettingsCubit>().setSemanticSearchMaxResults(value); 239 + context.read<SemanticSearchCubit>().setMaxResults(value); 240 + }, 241 + ), 256 242 ], 257 243 ), 258 244 ); ··· 372 358 SemanticSearchStatus.loaded when state.results.isEmpty => const _NoResultsView(), 373 359 SemanticSearchStatus.loaded => _ResultsList(results: state.results), 374 360 SemanticSearchStatus.error => _ErrorView(message: state.errorMessage), 375 - SemanticSearchStatus.unavailable => const _UnavailableView(), 376 361 }; 377 362 } 378 363 ··· 564 549 } 565 550 } 566 551 567 - class _DisabledView extends StatelessWidget { 568 - const _DisabledView(); 569 - 570 - @override 571 - Widget build(BuildContext context) { 572 - final scheme = context.colorScheme; 573 - return Center( 574 - child: SingleChildScrollView( 575 - padding: const EdgeInsets.all(32), 576 - child: Column( 577 - mainAxisSize: MainAxisSize.min, 578 - mainAxisAlignment: MainAxisAlignment.center, 579 - children: [ 580 - Icon(Icons.search_off_outlined, size: 64, color: scheme.outline), 581 - const SizedBox(height: 16), 582 - Text( 583 - 'Semantic search is off', 584 - style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 585 - ), 586 - const SizedBox(height: 8), 587 - Text( 588 - 'Turn it on above to search your saved and liked posts by meaning.', 589 - textAlign: TextAlign.center, 590 - style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 591 - ), 592 - ], 593 - ), 594 - ), 595 - ); 596 - } 597 - } 598 - 599 552 class _ErrorView extends StatelessWidget { 600 553 const _ErrorView({required this.message}); 601 554 ··· 615 568 ), 616 569 ); 617 570 } 618 - 619 - class _UnavailableView extends StatelessWidget { 620 - const _UnavailableView(); 621 - 622 - @override 623 - Widget build(BuildContext context) { 624 - final scheme = context.colorScheme; 625 - return Center( 626 - child: Padding( 627 - padding: const EdgeInsets.all(32), 628 - child: Column( 629 - mainAxisAlignment: MainAxisAlignment.center, 630 - children: [ 631 - Icon(Icons.model_training_outlined, size: 64, color: scheme.outline), 632 - const SizedBox(height: 16), 633 - Text( 634 - 'Semantic search unavailable', 635 - style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 636 - ), 637 - const SizedBox(height: 8), 638 - Text( 639 - 'The on-device language model could not be loaded on this device.', 640 - textAlign: TextAlign.center, 641 - style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 642 - ), 643 - ], 644 - ), 645 - ), 646 - ); 647 - } 648 - }
+3 -3
lib/features/settings/bloc/settings_cubit.dart
··· 89 89 simulateOffline: simulateOfflineStr == 'true', 90 90 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 91 91 constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, 92 - semanticSearchEnabled: semanticSearchEnabledStr == 'true', 92 + semanticSearchEnabled: semanticSearchEnabledStr != 'false', 93 93 searchScope: SearchScope.values.firstWhere((s) => s.name == searchScopeStr, orElse: () => SearchScope.both), 94 94 semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, 95 95 typeaheadProvider: resolvedTypeaheadProvider, ··· 152 152 } 153 153 154 154 Future<void> setSemanticSearchEnabled(bool value) async { 155 - await database.setSetting(_keySemanticSearchEnabled, value.toString()); 156 - emit(state.copyWith(semanticSearchEnabled: value)); 155 + await database.setSetting(_keySemanticSearchEnabled, 'true'); 156 + emit(state.copyWith(semanticSearchEnabled: true)); 157 157 } 158 158 159 159 Future<void> setSearchScope(SearchScope scope) async {
+1 -1
lib/features/settings/bloc/settings_state.dart
··· 15 15 this.simulateOffline = false, 16 16 this.threadAutoCollapseDepth, 17 17 this.constellationUrl = 'https://constellation.microcosm.blue', 18 - this.semanticSearchEnabled = false, 18 + this.semanticSearchEnabled = true, 19 19 this.searchScope = SearchScope.both, 20 20 this.semanticSearchMaxResults = 20, 21 21 this.typeaheadProvider = 'bluesky',
+147
test/core/network/actor_repository_service_resolver_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:http/http.dart' as http; 5 + import 'package:http/testing.dart'; 6 + import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 7 + 8 + void main() { 9 + group('ActorRepositoryServiceResolver', () { 10 + test('resolves handle through public identity host and then DID doc', () async { 11 + final requestedUris = <Uri>[]; 12 + final resolver = ActorRepositoryServiceResolver( 13 + resolveHandleHost: 'bsky.social', 14 + httpClient: MockClient((request) async { 15 + requestedUris.add(request.url); 16 + if (request.url.host == 'bsky.social' && request.url.path == '/xrpc/com.atproto.identity.resolveHandle') { 17 + return http.Response(jsonEncode({'did': 'did:plc:alice'}), 200); 18 + } 19 + if (request.url.host == 'plc.directory' && request.url.path == '/did:plc:alice') { 20 + return http.Response( 21 + jsonEncode({ 22 + 'service': [ 23 + { 24 + 'id': '#atproto_pds', 25 + 'type': 'AtprotoPersonalDataServer', 26 + 'serviceEndpoint': 'https://alice.us-east.host.bsky.network', 27 + }, 28 + ], 29 + }), 30 + 200, 31 + ); 32 + } 33 + return http.Response('not found', 404); 34 + }), 35 + ); 36 + 37 + final result = await resolver.resolve('alice.bsky.social'); 38 + 39 + expect(result.did, 'did:plc:alice'); 40 + expect(result.pdsHost, 'alice.us-east.host.bsky.network'); 41 + expect(requestedUris, hasLength(2)); 42 + expect(requestedUris.first.host, 'bsky.social'); 43 + expect(requestedUris.first.queryParameters['handle'], 'alice.bsky.social'); 44 + expect(requestedUris.last.host, 'plc.directory'); 45 + }); 46 + 47 + test('resolves did:web documents using did:web path mapping', () async { 48 + final requestedUris = <Uri>[]; 49 + final resolver = ActorRepositoryServiceResolver( 50 + httpClient: MockClient((request) async { 51 + requestedUris.add(request.url); 52 + if (request.url.host == 'example.com' && request.url.path == '/users/alice/did.json') { 53 + return http.Response( 54 + jsonEncode({ 55 + 'service': [ 56 + { 57 + 'id': '#atproto_pds', 58 + 'type': 'AtprotoPersonalDataServer', 59 + 'serviceEndpoint': 'https://pds.example.com', 60 + }, 61 + ], 62 + }), 63 + 200, 64 + ); 65 + } 66 + return http.Response('not found', 404); 67 + }), 68 + ); 69 + 70 + final result = await resolver.resolve('did:web:example.com:users:alice'); 71 + 72 + expect(result.did, 'did:web:example.com:users:alice'); 73 + expect(result.pdsHost, 'pds.example.com'); 74 + expect(requestedUris, hasLength(1)); 75 + expect(requestedUris.single.host, 'example.com'); 76 + expect(requestedUris.single.path, '/users/alice/did.json'); 77 + }); 78 + 79 + test('tries fallback identity hosts when preferred host fails', () async { 80 + final requestedHosts = <String>[]; 81 + final resolver = ActorRepositoryServiceResolver( 82 + resolveHandleHost: 'identity.invalid', 83 + httpClient: MockClient((request) async { 84 + requestedHosts.add(request.url.host); 85 + if (request.url.host == 'identity.invalid') { 86 + return http.Response('bad gateway', 502); 87 + } 88 + if (request.url.host == 'bsky.social') { 89 + return http.Response(jsonEncode({'did': 'did:plc:fallback'}), 200); 90 + } 91 + if (request.url.host == 'plc.directory' && request.url.path == '/did:plc:fallback') { 92 + return http.Response( 93 + jsonEncode({ 94 + 'service': [ 95 + { 96 + 'id': '#atproto_pds', 97 + 'type': 'AtprotoPersonalDataServer', 98 + 'serviceEndpoint': 'https://fallback.host', 99 + }, 100 + ], 101 + }), 102 + 200, 103 + ); 104 + } 105 + return http.Response('not found', 404); 106 + }), 107 + ); 108 + 109 + final result = await resolver.resolve('fallback.bsky.social'); 110 + 111 + expect(result.did, 'did:plc:fallback'); 112 + expect(result.pdsHost, 'fallback.host'); 113 + expect(requestedHosts.first, 'identity.invalid'); 114 + expect(requestedHosts.where((host) => host == 'bsky.social').isNotEmpty, isTrue); 115 + }); 116 + 117 + test('caches actor resolution and avoids duplicate network requests', () async { 118 + var callCount = 0; 119 + final resolver = ActorRepositoryServiceResolver( 120 + httpClient: MockClient((request) async { 121 + callCount++; 122 + if (request.url.host == 'bsky.social') { 123 + return http.Response(jsonEncode({'did': 'did:plc:cache'}), 200); 124 + } 125 + if (request.url.host == 'plc.directory') { 126 + return http.Response( 127 + jsonEncode({ 128 + 'service': [ 129 + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://cache.host'}, 130 + ], 131 + }), 132 + 200, 133 + ); 134 + } 135 + return http.Response('not found', 404); 136 + }), 137 + ); 138 + 139 + final first = await resolver.resolve('cache.bsky.social'); 140 + final second = await resolver.resolve('cache.bsky.social'); 141 + 142 + expect(first.did, second.did); 143 + expect(first.pdsHost, second.pdsHost); 144 + expect(callCount, 2); 145 + }); 146 + }); 147 + }
+1 -1
test/core/network/app_bsky_routing_policy_test.dart
··· 10 10 'app.bsky.actor.searchActorsTypeahead', 11 11 'app.bsky.graph.getList', 12 12 'app.bsky.graph.getLists', 13 + 'app.bsky.feed.getActorLikes', 13 14 'app.bsky.feed.getPosts', 14 15 'app.bsky.feed.getQuotes', 15 16 'app.bsky.unspecced.getTopicFeed', ··· 32 33 'app.bsky.feed.searchPosts', 33 34 'app.bsky.feed.getPostThread', 34 35 'app.bsky.feed.getAuthorFeed', 35 - 'app.bsky.feed.getActorLikes', 36 36 'app.bsky.notification.listNotifications', 37 37 'app.bsky.notification.getUnreadCount', 38 38 'app.bsky.notification.updateSeen',
+83 -30
test/features/devtools/cubit/dev_tools_cubit_test.dart
··· 26 26 int? limit, 27 27 String? cursor, 28 28 bool? reverse, 29 + String? serviceHost, 29 30 })? 30 31 listRecordsHandler; 31 - Future<RepoGetRecordOutput> Function({required String repo, required String collection, required String rkey})? 32 + Future<RepoGetRecordOutput> Function({ 33 + required String repo, 34 + required String collection, 35 + required String rkey, 36 + String? serviceHost, 37 + })? 32 38 getRecordHandler; 33 39 34 40 @override ··· 46 52 } 47 53 48 54 @override 49 - Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}) { 50 - return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey); 55 + Future<RepoGetRecordOutput> getRecord({ 56 + required String repo, 57 + required String collection, 58 + required String rkey, 59 + String? serviceHost, 60 + }) { 61 + return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey, serviceHost: serviceHost); 51 62 } 52 63 53 64 @override ··· 57 68 int? limit, 58 69 String? cursor, 59 70 bool? reverse, 71 + String? serviceHost, 60 72 }) { 61 - return listRecordsHandler!.call(repo: repo, collection: collection, limit: limit, cursor: cursor, reverse: reverse); 73 + return listRecordsHandler!.call( 74 + repo: repo, 75 + collection: collection, 76 + limit: limit, 77 + cursor: cursor, 78 + reverse: reverse, 79 + serviceHost: serviceHost, 80 + ); 62 81 } 63 82 64 83 @override ··· 132 151 describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput( 133 152 handle: 'alice.bsky.social', 134 153 did: 'did:plc:alice', 135 - didDoc: {}, 154 + didDoc: { 155 + 'service': [ 156 + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'}, 157 + ], 158 + }, 136 159 collections: ['app.bsky.feed.post'], 137 160 handleIsCorrect: true, 138 161 ), 139 162 listRecordsHandler: 140 - ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 163 + ({ 164 + required String repo, 165 + required String collection, 166 + int? limit, 167 + String? cursor, 168 + bool? reverse, 169 + String? serviceHost, 170 + }) async { 141 171 expect(repo, 'did:plc:alice'); 142 172 expect(collection, 'app.bsky.feed.post'); 173 + expect(serviceHost, 'alice.host'); 143 174 return RepoListRecordsOutput( 144 175 cursor: cursor == null ? 'next' : null, 145 176 records: [ ··· 182 213 describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput( 183 214 handle: 'alice.bsky.social', 184 215 did: 'did:plc:alice', 185 - didDoc: {}, 216 + didDoc: { 217 + 'service': [ 218 + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'}, 219 + ], 220 + }, 186 221 collections: ['app.bsky.feed.post'], 187 222 handleIsCorrect: true, 188 223 ), 189 224 listRecordsHandler: 190 - ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 225 + ({ 226 + required String repo, 227 + required String collection, 228 + int? limit, 229 + String? cursor, 230 + bool? reverse, 231 + String? serviceHost, 232 + }) async { 233 + expect(serviceHost, 'alice.host'); 191 234 if (limit == 100) { 192 235 return const RepoListRecordsOutput( 193 236 records: [ ··· 210 253 ], 211 254 ); 212 255 }, 213 - getRecordHandler: ({required String repo, required String collection, required String rkey}) async { 214 - expect(repo, 'did:plc:alice'); 215 - expect(collection, 'app.bsky.feed.post'); 216 - expect(rkey, '3kz'); 217 - return const RepoGetRecordOutput( 218 - uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 219 - cid: 'cid-full', 220 - value: { 221 - 'text': 'full record', 222 - 'nested': {'ok': true}, 256 + getRecordHandler: 257 + ({required String repo, required String collection, required String rkey, String? serviceHost}) async { 258 + expect(repo, 'did:plc:alice'); 259 + expect(collection, 'app.bsky.feed.post'); 260 + expect(rkey, '3kz'); 261 + expect(serviceHost, 'alice.host'); 262 + return const RepoGetRecordOutput( 263 + uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 264 + cid: 'cid-full', 265 + value: { 266 + 'text': 'full record', 267 + 'nested': {'ok': true}, 268 + }, 269 + ); 223 270 }, 224 - ); 225 - }, 226 271 ); 227 272 228 273 return DevToolsCubit(repository: repository); ··· 248 293 'loadRecord replaces list preview with getRecord response', 249 294 build: () { 250 295 final repository = FakeDevToolsRepository( 251 - getRecordHandler: ({required String repo, required String collection, required String rkey}) async { 252 - return const RepoGetRecordOutput( 253 - uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 254 - cid: 'cid123', 255 - value: { 256 - 'text': 'Expanded', 257 - 'reply': {'root': 'abc'}, 296 + getRecordHandler: 297 + ({required String repo, required String collection, required String rkey, String? serviceHost}) async { 298 + return const RepoGetRecordOutput( 299 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 300 + cid: 'cid123', 301 + value: { 302 + 'text': 'Expanded', 303 + 'reply': {'root': 'abc'}, 304 + }, 305 + ); 258 306 }, 259 - ); 260 - }, 261 307 ); 262 308 263 309 return DevToolsCubit(repository: repository); ··· 299 345 build: () { 300 346 final repository = FakeDevToolsRepository( 301 347 listRecordsHandler: 302 - ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 348 + ({ 349 + required String repo, 350 + required String collection, 351 + int? limit, 352 + String? cursor, 353 + bool? reverse, 354 + String? serviceHost, 355 + }) async { 303 356 return const RepoListRecordsOutput( 304 357 records: [ 305 358 RepoListRecordsRecord(
+1 -1
test/features/devtools/cubit/dev_tools_state_test.dart
··· 212 212 errorMessage: 'error', 213 213 ); 214 214 215 - expect(state.props.length, 15); 215 + expect(state.props.length, 16); 216 216 expect(state.props, contains(DevToolsStatus.repoLoaded)); 217 217 expect(state.props, contains(true)); 218 218 expect(state.props, contains('did:plc:test'));
+103 -3
test/features/feed/data/liked_posts_repository_test.dart
··· 174 174 expect(result, hasLength(2)); 175 175 }); 176 176 177 + test('continues pagination when a page has no inserts', () async { 178 + const knownUri = 'at://did:plc:author/app.bsky.feed.post/known'; 179 + const newUri = 'at://did:plc:author/app.bsky.feed.post/new'; 180 + await database.upsertLikedPost( 181 + LikedPostsCompanion( 182 + accountDid: const Value(_accountDid), 183 + postUri: const Value(knownUri), 184 + postJson: const Value('{}'), 185 + likedAt: Value(DateTime.utc(2026, 1, 1)), 186 + ), 187 + ); 188 + 189 + final feed = _FakeFeedService( 190 + pages: [ 191 + FeedGetActorLikesOutput(feed: [_makeFeedViewPost(knownUri)], cursor: 'page-2'), 192 + FeedGetActorLikesOutput(feed: [_makeFeedViewPost(newUri)]), 193 + ], 194 + ); 195 + final repo = LikedPostsRepository( 196 + bluesky: _FakeBluesky(feed: feed), 197 + database: database, 198 + ); 199 + 200 + await repo.syncLikes(_accountDid); 201 + 202 + expect(feed.callCount, 2); 203 + final likedPosts = await database.getLikedPosts(_accountDid, limit: 10); 204 + expect(likedPosts.map((e) => e.postUri), contains(newUri)); 205 + }); 206 + 207 + test('continues scanning page when known post appears before new post', () async { 208 + const knownPostUri = 'at://did:plc:author/app.bsky.feed.post/known'; 209 + const newPostUri = 'at://did:plc:author/app.bsky.feed.post/new'; 210 + await database.upsertLikedPost( 211 + LikedPostsCompanion( 212 + accountDid: const Value(_accountDid), 213 + postUri: const Value(knownPostUri), 214 + postJson: const Value('{}'), 215 + likedAt: Value(DateTime.now()), 216 + ), 217 + ); 218 + 219 + final repo = LikedPostsRepository( 220 + bluesky: _FakeBluesky( 221 + feed: _FakeFeedService( 222 + pages: [ 223 + FeedGetActorLikesOutput(feed: [_makeFeedViewPost(knownPostUri), _makeFeedViewPost(newPostUri)]), 224 + ], 225 + ), 226 + ), 227 + database: database, 228 + ); 229 + 230 + await repo.syncLikes(_accountDid); 231 + 232 + final likedPosts = await database.getLikedPosts(_accountDid, limit: 10); 233 + expect(likedPosts.map((e) => e.postUri), contains(newPostUri)); 234 + }); 235 + 177 236 test('stores postJson as valid JSON', () async { 178 237 final post = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post1'); 179 238 final repo = LikedPostsRepository( ··· 211 270 212 271 final otherResult = await database.getLikedPosts(_otherAccountDid); 213 272 expect(otherResult, isEmpty); 273 + }); 274 + 275 + test('updates existing liked row when incoming likedAt is newer', () async { 276 + const postUri = 'at://did:plc:author/app.bsky.feed.post/post1'; 277 + final firstRepo = LikedPostsRepository( 278 + bluesky: _FakeBluesky( 279 + feed: _FakeFeedService( 280 + pages: [ 281 + FeedGetActorLikesOutput( 282 + feed: [_makeFeedViewPost(postUri, indexedAt: DateTime.utc(2026, 1, 1, 0, 0, 0))], 283 + ), 284 + ], 285 + ), 286 + ), 287 + database: database, 288 + ); 289 + await firstRepo.syncLikes(_accountDid); 290 + 291 + final secondRepo = LikedPostsRepository( 292 + bluesky: _FakeBluesky( 293 + feed: _FakeFeedService( 294 + pages: [ 295 + FeedGetActorLikesOutput( 296 + feed: [_makeFeedViewPost(postUri, indexedAt: DateTime.utc(2026, 1, 2, 0, 0, 0))], 297 + ), 298 + ], 299 + ), 300 + ), 301 + database: database, 302 + ); 303 + await secondRepo.syncLikes(_accountDid); 304 + 305 + final stored = await database.getLikedPost(_accountDid, postUri); 306 + expect(stored, isNotNull); 307 + expect(stored!.likedAt.toUtc(), DateTime.utc(2026, 1, 2, 0, 0, 0)); 214 308 }); 215 309 }); 216 310 ··· 549 643 }); 550 644 } 551 645 552 - FeedViewPost _makeFeedViewPost(String uriStr) { 646 + FeedViewPost _makeFeedViewPost(String uriStr, {DateTime? indexedAt, DateTime? createdAt}) { 647 + final resolvedCreatedAt = createdAt ?? DateTime.utc(2026, 1, 1); 553 648 return FeedViewPost( 554 649 post: PostView( 555 650 uri: AtUri.parse(uriStr), 556 651 cid: 'cid-${uriStr.hashCode}', 557 652 author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 558 - record: const {r'$type': 'app.bsky.feed.post', 'text': 'Test post', 'createdAt': '2026-01-01T00:00:00.000Z'}, 559 - indexedAt: DateTime.utc(2026, 1, 1), 653 + record: { 654 + r'$type': 'app.bsky.feed.post', 655 + 'text': 'Test post', 656 + 'createdAt': resolvedCreatedAt.toUtc().toIso8601String(), 657 + }, 658 + indexedAt: indexedAt ?? DateTime.utc(2026, 1, 1), 560 659 ), 561 660 ); 562 661 } ··· 578 677 required String actor, 579 678 int? limit, 580 679 String? cursor, 680 + String? $service, 581 681 Map<String, String>? $headers, 582 682 }) async { 583 683 callCount++;
+239
test/features/profile/data/profile_repository_actor_likes_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:drift/native.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 8 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 9 + 10 + void main() { 11 + late AppDatabase database; 12 + 13 + setUp(() async { 14 + database = AppDatabase(executor: NativeDatabase.memory()); 15 + }); 16 + 17 + tearDown(() async { 18 + await database.close(); 19 + }); 20 + 21 + test('self likes path uses app.bsky.feed.getActorLikes only', () async { 22 + final feedService = _FakeFeedService( 23 + actorLikesPage: _FakeActorLikesData( 24 + feed: [ 25 + _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/1'), 26 + _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/2'), 27 + ], 28 + ), 29 + hydratedPosts: const [], 30 + ); 31 + final repoService = _FakeRepoService(recordsData: const _FakeListRecordsData(records: [])); 32 + final bluesky = _FakeBlueskyClient( 33 + session: const _FakeSession('did:plc:me', 'me.bsky.social'), 34 + feed: feedService, 35 + repo: repoService, 36 + ); 37 + final repository = ProfileRepository(database: database, bluesky: bluesky); 38 + 39 + final result = await repository.getActorLikes(actor: 'did:plc:me', limit: 50); 40 + 41 + expect(feedService.getActorLikesCallCount, 1); 42 + expect(repoService.listRecordsCallCount, 0); 43 + expect(feedService.getPostsCallCount, 0); 44 + expect(result.posts.length, 2); 45 + }); 46 + 47 + test('non-self likes path uses actor PDS listRecords + appview getPosts and keeps record order', () async { 48 + const actorDid = 'did:plc:friend'; 49 + const firstSubject = 'at://did:plc:author/app.bsky.feed.post/first'; 50 + const secondSubject = 'at://did:plc:author/app.bsky.feed.post/second'; 51 + final feedService = _FakeFeedService( 52 + actorLikesPage: const _FakeActorLikesData(feed: []), 53 + hydratedPosts: [_makePostView(secondSubject)], 54 + ); 55 + final repoService = _FakeRepoService( 56 + recordsData: const _FakeListRecordsData( 57 + records: [ 58 + _FakeRepoRecord( 59 + value: { 60 + 'subject': {'uri': firstSubject}, 61 + 'createdAt': '2026-05-02T01:34:47.734Z', 62 + }, 63 + ), 64 + _FakeRepoRecord( 65 + value: { 66 + 'subject': {'uri': secondSubject}, 67 + 'createdAt': '2026-05-02T01:00:00.000Z', 68 + }, 69 + ), 70 + ], 71 + ), 72 + ); 73 + final actorRepoResolver = _FakeActorRepositoryServiceResolver( 74 + const ActorRepositoryServiceResolution(actor: actorDid, did: actorDid, pdsHost: 'friend.host'), 75 + ); 76 + final bluesky = _FakeBlueskyClient( 77 + session: const _FakeSession('did:plc:me', 'me.bsky.social'), 78 + feed: feedService, 79 + repo: repoService, 80 + ); 81 + final repository = ProfileRepository( 82 + database: database, 83 + bluesky: bluesky, 84 + appViewProvider: 'bluesky', 85 + actorRepositoryServiceResolver: actorRepoResolver, 86 + ); 87 + 88 + final result = await repository.getActorLikes(actor: actorDid, limit: 50); 89 + 90 + expect(actorRepoResolver.resolveCallCount, 1); 91 + expect(repoService.listRecordsCallCount, 1); 92 + expect(repoService.lastReverse, isFalse); 93 + expect(repoService.lastServiceHost, 'friend.host'); 94 + expect(feedService.getActorLikesCallCount, 0); 95 + expect(feedService.getPostsCallCount, 1); 96 + expect(feedService.lastGetPostsServiceHost, 'public.api.bsky.app'); 97 + expect(result.entries.length, 2); 98 + expect(result.entries.first.isAvailable, isFalse); 99 + expect(result.entries.first.subjectUri, firstSubject); 100 + expect(result.entries.last.isAvailable, isTrue); 101 + expect(result.entries.last.feedViewPost?.post.uri.toString(), secondSubject); 102 + }); 103 + } 104 + 105 + FeedViewPost _makeFeedViewPost(String uri) => FeedViewPost(post: _makePostView(uri)); 106 + 107 + PostView _makePostView(String uri) { 108 + return PostView( 109 + uri: AtUri.parse(uri), 110 + cid: 'cid-$uri', 111 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 112 + record: const {r'$type': 'app.bsky.feed.post', 'text': 'hello', 'createdAt': '2026-05-01T00:00:00.000Z'}, 113 + indexedAt: DateTime.utc(2026, 5, 1), 114 + ); 115 + } 116 + 117 + class _FakeBlueskyClient { 118 + _FakeBlueskyClient({required this.session, required this.feed, required _FakeRepoService repo}) 119 + : atproto = _FakeAtprotoClient(repo: repo); 120 + 121 + final _FakeSession session; 122 + final _FakeFeedService feed; 123 + final _FakeAtprotoClient atproto; 124 + } 125 + 126 + class _FakeSession { 127 + const _FakeSession(this.did, this.handle); 128 + 129 + final String did; 130 + final String handle; 131 + } 132 + 133 + class _FakeAtprotoClient { 134 + const _FakeAtprotoClient({required this.repo}); 135 + 136 + final _FakeRepoService repo; 137 + } 138 + 139 + class _FakeFeedService { 140 + _FakeFeedService({required _FakeActorLikesData actorLikesPage, required List<PostView> hydratedPosts}) 141 + : _actorLikesPage = actorLikesPage, 142 + _hydratedPosts = hydratedPosts; 143 + 144 + final _FakeActorLikesData _actorLikesPage; 145 + final List<PostView> _hydratedPosts; 146 + int getActorLikesCallCount = 0; 147 + int getPostsCallCount = 0; 148 + String? lastGetPostsServiceHost; 149 + 150 + Future<_FakeResponse<_FakeActorLikesData>> getActorLikes({ 151 + required String actor, 152 + String? cursor, 153 + int? limit, 154 + Map<String, String>? $headers, 155 + }) async { 156 + if (cursor != null && cursor.isNotEmpty) {} 157 + getActorLikesCallCount++; 158 + return _FakeResponse(_actorLikesPage); 159 + } 160 + 161 + Future<_FakeResponse<_FakeGetPostsData>> getPosts({ 162 + required List<AtUri> uris, 163 + String? $service, 164 + Map<String, String>? $headers, 165 + }) async { 166 + getPostsCallCount++; 167 + lastGetPostsServiceHost = $service; 168 + return _FakeResponse(_FakeGetPostsData(posts: _hydratedPosts)); 169 + } 170 + } 171 + 172 + class _FakeRepoService { 173 + _FakeRepoService({required _FakeListRecordsData recordsData}) : _recordsData = recordsData; 174 + 175 + final _FakeListRecordsData _recordsData; 176 + int listRecordsCallCount = 0; 177 + String? lastServiceHost; 178 + bool? lastReverse; 179 + 180 + Future<_FakeResponse<_FakeListRecordsData>> listRecords({ 181 + required String repo, 182 + required String collection, 183 + int? limit, 184 + String? cursor, 185 + bool? reverse, 186 + String? $service, 187 + }) async { 188 + if (cursor != null && cursor.isNotEmpty) {} 189 + listRecordsCallCount++; 190 + lastServiceHost = $service; 191 + lastReverse = reverse; 192 + return _FakeResponse(_recordsData); 193 + } 194 + } 195 + 196 + class _FakeResponse<T> { 197 + const _FakeResponse(this.data); 198 + 199 + final T data; 200 + } 201 + 202 + class _FakeActorLikesData { 203 + const _FakeActorLikesData({required this.feed}) : cursor = null; 204 + 205 + final List<FeedViewPost> feed; 206 + final String? cursor; 207 + } 208 + 209 + class _FakeGetPostsData { 210 + const _FakeGetPostsData({required this.posts}); 211 + 212 + final List<PostView> posts; 213 + } 214 + 215 + class _FakeListRecordsData { 216 + const _FakeListRecordsData({required this.records}) : cursor = null; 217 + 218 + final List<_FakeRepoRecord> records; 219 + final String? cursor; 220 + } 221 + 222 + class _FakeRepoRecord { 223 + const _FakeRepoRecord({required this.value}); 224 + 225 + final Map<String, dynamic> value; 226 + } 227 + 228 + class _FakeActorRepositoryServiceResolver extends ActorRepositoryServiceResolver { 229 + _FakeActorRepositoryServiceResolver(this._resolution) : super(); 230 + 231 + final ActorRepositoryServiceResolution _resolution; 232 + int resolveCallCount = 0; 233 + 234 + @override 235 + Future<ActorRepositoryServiceResolution> resolve(String actor) async { 236 + resolveCallCount++; 237 + return _resolution; 238 + } 239 + }
+34 -1
test/features/profile/data/profile_repository_test.dart
··· 114 114 expect(result.did, profile.did); 115 115 expect(result.followersCount, profile.followersCount); 116 116 }); 117 + 118 + test('paginates getProfiles requests in batches of 25 actors', () async { 119 + final requestedBatches = <List<String>>[]; 120 + final actors = List<String>.generate(26, (index) => 'did:plc:actor$index'); 121 + final repository = ProfileRepository( 122 + database: database, 123 + bluesky: _FakeBlueskyClient( 124 + actor: _FakeActorService( 125 + onGetProfile: (_) async => throw UnimplementedError(), 126 + onGetProfiles: (batch) async { 127 + requestedBatches.add(List<String>.from(batch)); 128 + final profiles = batch 129 + .map((did) => ProfileView(did: did, handle: '$did.bsky.social', indexedAt: DateTime.utc(2026))) 130 + .toList(growable: false); 131 + return _FakeProfilesResponse(_FakeProfilesData(profiles)); 132 + }, 133 + ), 134 + ), 135 + ); 136 + 137 + final profiles = await repository.getProfiles(actors); 138 + 139 + expect(requestedBatches.length, 2); 140 + expect(requestedBatches[0].length, 25); 141 + expect(requestedBatches[1].length, 1); 142 + expect(profiles.length, 26); 143 + expect(profiles.map((p) => p.did), orderedEquals(actors)); 144 + }); 117 145 }); 118 146 } 119 147 ··· 138 166 } 139 167 140 168 class _FakeActorService { 141 - _FakeActorService({required this.onGetProfile}); 169 + _FakeActorService({required this.onGetProfile, this.onGetProfiles}); 142 170 143 171 final Future<_FakeResponse<ProfileViewDetailed>> Function(String actor) onGetProfile; 172 + final Future<_FakeProfilesResponse> Function(List<String> actors)? onGetProfiles; 144 173 145 174 Future<_FakeResponse<ProfileViewDetailed>> getProfile({required String actor, Map<String, String>? $headers}) { 146 175 return onGetProfile(actor); 147 176 } 148 177 149 178 Future<_FakeProfilesResponse> getProfiles({required List<String> actors, Map<String, String>? $headers}) async { 179 + final handler = onGetProfiles; 180 + if (handler != null) { 181 + return handler(actors); 182 + } 150 183 return _FakeProfilesResponse(const _FakeProfilesData([])); 151 184 } 152 185 }
+18 -8
test/features/search/cubit/semantic_search_cubit_test.dart
··· 61 61 expect(cubit.state.errorMessage, isNull); 62 62 }); 63 63 64 - test('is unavailable when embedding service is not available', () { 64 + test('starts in initial state when embedding service is not available', () { 65 65 final cubit = SemanticSearchCubit( 66 66 repository: mockRepo, 67 67 embeddingService: _unavailableService(), 68 68 accountDid: _accountDid, 69 69 ); 70 - expect(cubit.state.status, SemanticSearchStatus.unavailable); 70 + expect(cubit.state.status, SemanticSearchStatus.initial); 71 71 }); 72 72 73 - test('recovers from startup race once embedding service initializes', () async { 73 + test('remains initial after startup race recovery attempt', () async { 74 74 final lateInitService = EmbeddingService.forTesting((_) async => Float32List.fromList(List.filled(384, 0.2))); 75 75 final cubit = SemanticSearchCubit( 76 76 repository: mockRepo, ··· 78 78 accountDid: _accountDid, 79 79 ); 80 80 81 - expect(cubit.state.status, SemanticSearchStatus.unavailable); 81 + expect(cubit.state.status, SemanticSearchStatus.initial); 82 82 await Future<void>.delayed(Duration.zero); 83 83 expect(cubit.state.status, SemanticSearchStatus.initial); 84 84 }); ··· 235 235 ); 236 236 237 237 blocTest<SemanticSearchCubit, SemanticSearchState>( 238 - 'keeps unavailable state when service is not available', 238 + 'emits error when service is not available', 239 239 build: () => 240 - SemanticSearchCubit(repository: mockRepo, embeddingService: _unavailableService(), accountDid: _accountDid), 240 + SemanticSearchCubit( 241 + repository: mockRepo, 242 + embeddingService: _unavailableService(), 243 + accountDid: _accountDid, 244 + debounceDuration: Duration.zero, 245 + ), 241 246 act: (cubit) => cubit.search('flutter'), 242 - expect: () => [], 247 + expect: () => [ 248 + predicate<SemanticSearchState>( 249 + (s) => 250 + s.status == SemanticSearchStatus.error && 251 + (s.errorMessage?.contains('Semantic model unavailable') ?? false), 252 + ), 253 + ], 243 254 verify: (cubit) { 244 - expect(cubit.state.status, SemanticSearchStatus.unavailable); 245 255 verifyNever(() => mockRepo.search(any(), any())); 246 256 }, 247 257 );
+5 -16
test/features/search/presentation/semantic_search_tab_test.dart
··· 112 112 expect(find.text('Search your saved and liked posts by meaning, not just keywords'), findsOneWidget); 113 113 }); 114 114 115 - testWidgets('shows unavailable state when embedding service is unavailable', (tester) async { 116 - when(() => searchCubit.state).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.unavailable)); 117 - whenListen( 118 - searchCubit, 119 - const Stream<SemanticSearchState>.empty(), 120 - initialState: const SemanticSearchState(status: SemanticSearchStatus.unavailable), 121 - ); 122 - await tester.pumpWidget(buildSubject()); 123 - expect(find.text('Semantic search unavailable'), findsOneWidget); 124 - }); 125 - 126 115 testWidgets('shows loading indicator while searching', (tester) async { 127 116 when(() => searchCubit.state).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.searching)); 128 117 whenListen( ··· 266 255 await tester.tap(find.text('Semantic settings')); 267 256 await tester.pumpAndSettle(); 268 257 expect(find.text('Semantic settings'), findsOneWidget); 269 - expect(find.text('Enable semantic search'), findsOneWidget); 258 + expect(find.text('Semantic search is always enabled for saved and liked posts.'), findsOneWidget); 270 259 }); 271 260 272 - testWidgets('sheet toggle updates semantic search enabled setting', (tester) async { 261 + testWidgets('semantic settings sheet does not show enable toggle', (tester) async { 273 262 await tester.pumpWidget(buildSubject()); 274 263 await tester.tap(find.byTooltip('Search index actions')); 275 264 await tester.pumpAndSettle(); 276 265 await tester.tap(find.text('Semantic settings')); 277 266 await tester.pumpAndSettle(); 278 - await tester.tap(find.byType(Switch)); 279 - await tester.pumpAndSettle(); 280 - verify(() => settingsCubit.setSemanticSearchEnabled(false)).called(1); 267 + expect(find.byType(Switch), findsNothing); 268 + expect(find.text('Default scope'), findsOneWidget); 269 + expect(find.text('Max results'), findsOneWidget); 281 270 }); 282 271 }); 283 272 }