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.

feat: add DevTools PDS Explorer with repository and record browsing

+2031 -3
+5 -1
lib/core/router/app_router.dart
··· 5 5 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 6 import 'package:lazurite/core/router/app_shell.dart'; 7 7 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 8 + import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 8 9 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 9 10 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 10 11 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; ··· 75 76 GoRoute( 76 77 path: '/settings', 77 78 builder: (context, state) => const SettingsScreen(), 78 - routes: [GoRoute(path: 'logs', builder: (context, state) => const LogsScreen())], 79 + routes: [ 80 + GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 81 + GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 82 + ], 79 83 ), 80 84 ], 81 85 ),
+473
lib/features/devtools/cubit/dev_tools_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto/atproto.dart'; 4 + import 'package:atproto/com_atproto_identity_resolvehandle.dart'; 5 + import 'package:atproto/com_atproto_repo_describerepo.dart'; 6 + import 'package:atproto/com_atproto_repo_getrecord.dart'; 7 + import 'package:atproto/com_atproto_repo_listrecords.dart'; 8 + import 'package:atproto_core/atproto_core.dart'; 9 + import 'package:equatable/equatable.dart'; 10 + import 'package:flutter_bloc/flutter_bloc.dart'; 11 + import 'package:lazurite/core/logging/app_logger.dart'; 12 + 13 + part 'dev_tools_state.dart'; 14 + 15 + abstract interface class DevToolsRepository { 16 + Future<IdentityResolveHandleOutput> resolveHandle({required String handle}); 17 + 18 + Future<RepoDescribeRepoOutput> describeRepo({required String repo}); 19 + 20 + Future<RepoListRecordsOutput> listRecords({ 21 + required String repo, 22 + required String collection, 23 + int? limit, 24 + String? cursor, 25 + bool? reverse, 26 + }); 27 + 28 + Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}); 29 + } 30 + 31 + final class AtprotoDevToolsRepository implements DevToolsRepository { 32 + const AtprotoDevToolsRepository({required ATProto atproto}) : _atproto = atproto; 33 + 34 + final ATProto _atproto; 35 + 36 + @override 37 + Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) async { 38 + final response = await _atproto.identity.resolveHandle(handle: handle); 39 + return response.data; 40 + } 41 + 42 + @override 43 + Future<RepoDescribeRepoOutput> describeRepo({required String repo}) async { 44 + final response = await _atproto.repo.describeRepo(repo: repo); 45 + return response.data; 46 + } 47 + 48 + @override 49 + Future<RepoListRecordsOutput> listRecords({ 50 + required String repo, 51 + required String collection, 52 + int? limit, 53 + String? cursor, 54 + bool? reverse, 55 + }) async { 56 + final response = await _atproto.repo.listRecords( 57 + repo: repo, 58 + collection: collection, 59 + limit: limit, 60 + cursor: cursor, 61 + reverse: reverse, 62 + ); 63 + return response.data; 64 + } 65 + 66 + @override 67 + Future<RepoGetRecordOutput> getRecord({ 68 + required String repo, 69 + required String collection, 70 + required String rkey, 71 + }) async { 72 + final response = await _atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey); 73 + return response.data; 74 + } 75 + } 76 + 77 + class DevToolsCubit extends Cubit<DevToolsState> { 78 + DevToolsCubit({ATProto? atproto, DevToolsRepository? repository}) 79 + : assert(atproto != null || repository != null, 'Provide either atproto or repository'), 80 + _repository = repository ?? AtprotoDevToolsRepository(atproto: atproto!), 81 + super(const DevToolsState()); 82 + 83 + static const _pageSize = 50; 84 + static const _countPageSize = 100; 85 + 86 + final DevToolsRepository _repository; 87 + int _resolveRequestId = 0; 88 + int _collectionRequestId = 0; 89 + int _recordRequestId = 0; 90 + 91 + Future<void> resolve(String input) async { 92 + final query = input.trim(); 93 + if (query.isEmpty) { 94 + clearInput(); 95 + return; 96 + } 97 + 98 + final resolveRequestId = _beginResolveRequest(); 99 + emit(const DevToolsState(status: DevToolsStatus.loading)); 100 + 101 + try { 102 + if (query.startsWith('at://')) { 103 + await _resolveAtUri(query, resolveRequestId); 104 + return; 105 + } 106 + 107 + final identity = await _resolveIdentity(query); 108 + if (!_isActiveResolveRequest(resolveRequestId)) { 109 + return; 110 + } 111 + 112 + final repo = await _repository.describeRepo(repo: identity.did); 113 + if (!_isActiveResolveRequest(resolveRequestId)) { 114 + return; 115 + } 116 + 117 + emit(_buildRepoState(repo: repo, did: identity.did, handle: identity.handle, status: DevToolsStatus.repoLoaded)); 118 + unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 119 + } catch (error, stackTrace) { 120 + log.e('DevToolsCubit: Failed to resolve repo', error: error, stackTrace: stackTrace); 121 + if (_isActiveResolveRequest(resolveRequestId)) { 122 + emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 123 + } 124 + } 125 + } 126 + 127 + Future<void> loadCollection(String collection) async { 128 + if (state.did == null) return; 129 + 130 + final collectionRequestId = _beginCollectionRequest(); 131 + emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); 132 + 133 + try { 134 + final response = await _repository.listRecords(repo: state.did!, collection: collection, limit: _pageSize); 135 + if (!_isActiveCollectionRequest(collectionRequestId)) { 136 + return; 137 + } 138 + 139 + emit( 140 + state.copyWith( 141 + status: DevToolsStatus.collectionLoaded, 142 + selectedCollection: collection, 143 + records: response.records, 144 + recordsCursor: response.cursor, 145 + selectedRecord: null, 146 + errorMessage: null, 147 + ), 148 + ); 149 + } catch (error, stackTrace) { 150 + log.e('DevToolsCubit: Failed to load collection', error: error, stackTrace: stackTrace); 151 + if (_isActiveCollectionRequest(collectionRequestId)) { 152 + emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 153 + } 154 + } 155 + } 156 + 157 + Future<void> loadMoreRecords() async { 158 + if (state.did == null || state.selectedCollection == null || state.recordsCursor == null) return; 159 + 160 + emit(state.copyWith(status: DevToolsStatus.loadingMore, errorMessage: null)); 161 + 162 + final activeCollectionRequestId = _collectionRequestId; 163 + try { 164 + final response = await _repository.listRecords( 165 + repo: state.did!, 166 + collection: state.selectedCollection!, 167 + cursor: state.recordsCursor, 168 + limit: _pageSize, 169 + ); 170 + if (!_isActiveCollectionRequest(activeCollectionRequestId)) { 171 + return; 172 + } 173 + 174 + emit( 175 + state.copyWith( 176 + status: DevToolsStatus.collectionLoaded, 177 + records: [...?state.records, ...response.records], 178 + recordsCursor: response.cursor, 179 + errorMessage: null, 180 + ), 181 + ); 182 + } catch (error, stackTrace) { 183 + log.e('DevToolsCubit: Failed to load more records', error: error, stackTrace: stackTrace); 184 + if (_isActiveCollectionRequest(activeCollectionRequestId)) { 185 + emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 186 + } 187 + } 188 + } 189 + 190 + Future<void> loadRecord(RepoListRecordsRecord record) async { 191 + if (state.did == null) return; 192 + 193 + final recordRequestId = _beginRecordRequest(); 194 + emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); 195 + 196 + try { 197 + final resolvedRecord = await _repository.getRecord( 198 + repo: state.did!, 199 + collection: record.uri.collection.toString(), 200 + rkey: record.uri.rkey, 201 + ); 202 + if (!_isActiveRecordRequest(recordRequestId)) { 203 + return; 204 + } 205 + 206 + emit( 207 + state.copyWith( 208 + status: DevToolsStatus.recordLoaded, 209 + selectedRecord: RecordInfo( 210 + uri: resolvedRecord.uri.toString(), 211 + cid: resolvedRecord.cid, 212 + value: resolvedRecord.value, 213 + ), 214 + errorMessage: null, 215 + ), 216 + ); 217 + } catch (error, stackTrace) { 218 + log.e('DevToolsCubit: Failed to load record', error: error, stackTrace: stackTrace); 219 + if (_isActiveRecordRequest(recordRequestId)) { 220 + emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 221 + } 222 + } 223 + } 224 + 225 + void goBackToCollection() { 226 + _recordRequestId++; 227 + if (state.selectedCollection != null) { 228 + emit(state.copyWith(status: DevToolsStatus.collectionLoaded, selectedRecord: null)); 229 + } else { 230 + emit( 231 + state.copyWith( 232 + status: DevToolsStatus.repoLoaded, 233 + selectedCollection: null, 234 + records: null, 235 + recordsCursor: null, 236 + selectedRecord: null, 237 + ), 238 + ); 239 + } 240 + } 241 + 242 + void goBackToRepo() { 243 + _collectionRequestId++; 244 + _recordRequestId++; 245 + emit( 246 + state.copyWith( 247 + status: DevToolsStatus.repoLoaded, 248 + selectedCollection: null, 249 + records: null, 250 + recordsCursor: null, 251 + selectedRecord: null, 252 + ), 253 + ); 254 + } 255 + 256 + void clearInput() { 257 + _beginResolveRequest(); 258 + emit(const DevToolsState()); 259 + } 260 + 261 + int _beginResolveRequest() { 262 + _resolveRequestId++; 263 + _collectionRequestId++; 264 + _recordRequestId++; 265 + return _resolveRequestId; 266 + } 267 + 268 + int _beginCollectionRequest() { 269 + _collectionRequestId++; 270 + _recordRequestId++; 271 + return _collectionRequestId; 272 + } 273 + 274 + int _beginRecordRequest() { 275 + _recordRequestId++; 276 + return _recordRequestId; 277 + } 278 + 279 + bool _isActiveResolveRequest(int requestId) => requestId == _resolveRequestId; 280 + 281 + bool _isActiveCollectionRequest(int requestId) => requestId == _collectionRequestId; 282 + 283 + bool _isActiveRecordRequest(int requestId) => requestId == _recordRequestId; 284 + 285 + Future<void> _resolveAtUri(String input, int resolveRequestId) async { 286 + final atUri = _parseAtUri(input); 287 + final identity = await _resolveIdentity(atUri.hostname); 288 + if (!_isActiveResolveRequest(resolveRequestId)) { 289 + return; 290 + } 291 + 292 + final repo = await _repository.describeRepo(repo: identity.did); 293 + if (!_isActiveResolveRequest(resolveRequestId)) { 294 + return; 295 + } 296 + 297 + final collection = _collectionFromAtUri(atUri); 298 + final rkey = _rkeyFromAtUri(atUri); 299 + 300 + if (collection == null) { 301 + emit(_buildRepoState(repo: repo, did: identity.did, handle: identity.handle, status: DevToolsStatus.repoLoaded)); 302 + unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 303 + return; 304 + } 305 + 306 + final records = await _repository.listRecords(repo: identity.did, collection: collection, limit: _pageSize); 307 + if (!_isActiveResolveRequest(resolveRequestId)) { 308 + return; 309 + } 310 + 311 + if (rkey == null) { 312 + emit( 313 + _buildRepoState( 314 + repo: repo, 315 + did: identity.did, 316 + handle: identity.handle, 317 + status: DevToolsStatus.collectionLoaded, 318 + selectedCollection: collection, 319 + records: records.records, 320 + recordsCursor: records.cursor, 321 + ), 322 + ); 323 + unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 324 + return; 325 + } 326 + 327 + final record = await _repository.getRecord(repo: identity.did, collection: collection, rkey: rkey); 328 + if (!_isActiveResolveRequest(resolveRequestId)) { 329 + return; 330 + } 331 + 332 + emit( 333 + _buildRepoState( 334 + repo: repo, 335 + did: identity.did, 336 + handle: identity.handle, 337 + status: DevToolsStatus.recordLoaded, 338 + selectedCollection: collection, 339 + records: records.records, 340 + recordsCursor: records.cursor, 341 + selectedRecord: RecordInfo(uri: record.uri.toString(), cid: record.cid, value: record.value), 342 + ), 343 + ); 344 + unawaited(_loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did)); 345 + } 346 + 347 + Future<({String did, String? handle})> _resolveIdentity(String input) async { 348 + if (input.startsWith('did:')) { 349 + return (did: input, handle: null); 350 + } 351 + 352 + final response = await _repository.resolveHandle(handle: input); 353 + return (did: response.did, handle: input); 354 + } 355 + 356 + AtUri _parseAtUri(String input) { 357 + try { 358 + return AtUri.parse(input); 359 + } on FormatException { 360 + throw const FormatException('Invalid AT-URI'); 361 + } 362 + } 363 + 364 + String? _collectionFromAtUri(AtUri atUri) { 365 + final segments = atUri.pathname.split('/').where((segment) => segment.isNotEmpty).toList(); 366 + if (segments.isEmpty) { 367 + return null; 368 + } 369 + 370 + return segments.first; 371 + } 372 + 373 + String? _rkeyFromAtUri(AtUri atUri) { 374 + final segments = atUri.pathname.split('/').where((segment) => segment.isNotEmpty).toList(); 375 + if (segments.length < 2) { 376 + return null; 377 + } 378 + 379 + return segments[1]; 380 + } 381 + 382 + DevToolsState _buildRepoState({ 383 + required RepoDescribeRepoOutput repo, 384 + required String did, 385 + required String? handle, 386 + required DevToolsStatus status, 387 + String? selectedCollection, 388 + List<RepoListRecordsRecord>? records, 389 + String? recordsCursor, 390 + RecordInfo? selectedRecord, 391 + }) { 392 + return DevToolsState( 393 + status: status, 394 + did: did, 395 + handle: handle ?? repo.handle, 396 + repoHandle: repo.handle, 397 + collections: repo.collections.map(CollectionSummary.new).toList(growable: false), 398 + isCollectionCountsLoading: repo.collections.isNotEmpty, 399 + selectedCollection: selectedCollection, 400 + records: records, 401 + recordsCursor: recordsCursor, 402 + selectedRecord: selectedRecord, 403 + ); 404 + } 405 + 406 + Future<void> _loadCollectionCounts({required int resolveRequestId, required String did}) async { 407 + if (state.collections.isEmpty) { 408 + if (_isActiveResolveRequest(resolveRequestId)) { 409 + emit(state.copyWith(isCollectionCountsLoading: false)); 410 + } 411 + return; 412 + } 413 + 414 + final countsByCollection = <String, int>{}; 415 + for (final collection in state.collections) { 416 + if (!_isActiveResolveRequest(resolveRequestId)) { 417 + return; 418 + } 419 + 420 + countsByCollection[collection.name] = await _countRecords(did: did, collection: collection.name); 421 + if (!_isActiveResolveRequest(resolveRequestId)) { 422 + return; 423 + } 424 + 425 + emit( 426 + state.copyWith( 427 + collections: _mergeCollectionCounts(state.collections, countsByCollection), 428 + isCollectionCountsLoading: countsByCollection.length < state.collections.length, 429 + ), 430 + ); 431 + } 432 + } 433 + 434 + Future<int> _countRecords({required String did, required String collection}) async { 435 + var total = 0; 436 + String? cursor; 437 + 438 + do { 439 + final response = await _repository.listRecords( 440 + repo: did, 441 + collection: collection, 442 + limit: _countPageSize, 443 + cursor: cursor, 444 + ); 445 + total += response.records.length; 446 + cursor = response.cursor; 447 + } while (cursor != null && cursor.isNotEmpty); 448 + 449 + return total; 450 + } 451 + 452 + List<CollectionSummary> _mergeCollectionCounts( 453 + List<CollectionSummary> collections, 454 + Map<String, int> countsByCollection, 455 + ) { 456 + return collections 457 + .map( 458 + (collection) => CollectionSummary( 459 + collection.name, 460 + recordCount: countsByCollection[collection.name] ?? collection.recordCount, 461 + ), 462 + ) 463 + .toList(growable: false); 464 + } 465 + 466 + String _formatError(Object error) { 467 + if (error is FormatException) { 468 + return error.message; 469 + } 470 + 471 + return error.toString(); 472 + } 473 + }
+128
lib/features/devtools/cubit/dev_tools_state.dart
··· 1 + part of 'dev_tools_cubit.dart'; 2 + 3 + enum DevToolsStatus { initial, loading, repoLoaded, collectionLoaded, recordLoaded, loadingMore, error } 4 + 5 + class CollectionSummary extends Equatable { 6 + const CollectionSummary(this.name, {this.recordCount}); 7 + 8 + final String name; 9 + final int? recordCount; 10 + 11 + String get countLabel => recordCount == null ? '...' : '$recordCount'; 12 + 13 + @override 14 + List<Object?> get props => [name, recordCount]; 15 + } 16 + 17 + class RecordInfo extends Equatable { 18 + const RecordInfo({required this.uri, this.cid, required this.value}); 19 + 20 + final String uri; 21 + final String? cid; 22 + final Map<String, dynamic> value; 23 + 24 + String get rkey { 25 + try { 26 + return AtUri.parse(uri).rkey; 27 + } catch (_) { 28 + return ''; 29 + } 30 + } 31 + 32 + String get atUriToLink { 33 + final uriString = uri.replaceFirst('at://', ''); 34 + return 'https://aturi.to/$uriString'; 35 + } 36 + 37 + @override 38 + List<Object?> get props => [uri, cid, value]; 39 + } 40 + 41 + const _devToolsStateNoChange = Object(); 42 + 43 + class DevToolsState extends Equatable { 44 + const DevToolsState({ 45 + this.status = DevToolsStatus.initial, 46 + this.did, 47 + this.handle, 48 + this.repoHandle, 49 + this.collections = const [], 50 + this.isCollectionCountsLoading = false, 51 + this.selectedCollection, 52 + this.records, 53 + this.recordsCursor, 54 + this.selectedRecord, 55 + this.errorMessage, 56 + }); 57 + 58 + final DevToolsStatus status; 59 + final String? did; 60 + final String? handle; 61 + final String? repoHandle; 62 + final List<CollectionSummary> collections; 63 + final bool isCollectionCountsLoading; 64 + final String? selectedCollection; 65 + final List<RepoListRecordsRecord>? records; 66 + final String? recordsCursor; 67 + final RecordInfo? selectedRecord; 68 + final String? errorMessage; 69 + 70 + bool get isLoading => status == DevToolsStatus.loading || status == DevToolsStatus.loadingMore; 71 + bool get hasMoreRecords => recordsCursor != null && recordsCursor!.isNotEmpty; 72 + int get totalRecords => records?.length ?? 0; 73 + int? get totalRepoRecords { 74 + if (collections.isEmpty) { 75 + return 0; 76 + } 77 + if (collections.any((collection) => collection.recordCount == null)) { 78 + return null; 79 + } 80 + 81 + return collections.fold<int>(0, (sum, collection) => sum + collection.recordCount!); 82 + } 83 + 84 + DevToolsState copyWith({ 85 + DevToolsStatus? status, 86 + Object? did = _devToolsStateNoChange, 87 + Object? handle = _devToolsStateNoChange, 88 + Object? repoHandle = _devToolsStateNoChange, 89 + List<CollectionSummary>? collections, 90 + bool? isCollectionCountsLoading, 91 + Object? selectedCollection = _devToolsStateNoChange, 92 + Object? records = _devToolsStateNoChange, 93 + Object? recordsCursor = _devToolsStateNoChange, 94 + Object? selectedRecord = _devToolsStateNoChange, 95 + Object? errorMessage = _devToolsStateNoChange, 96 + }) { 97 + return DevToolsState( 98 + status: status ?? this.status, 99 + did: identical(did, _devToolsStateNoChange) ? this.did : did as String?, 100 + handle: identical(handle, _devToolsStateNoChange) ? this.handle : handle as String?, 101 + repoHandle: identical(repoHandle, _devToolsStateNoChange) ? this.repoHandle : repoHandle as String?, 102 + collections: collections ?? this.collections, 103 + isCollectionCountsLoading: isCollectionCountsLoading ?? this.isCollectionCountsLoading, 104 + selectedCollection: identical(selectedCollection, _devToolsStateNoChange) 105 + ? this.selectedCollection 106 + : selectedCollection as String?, 107 + records: identical(records, _devToolsStateNoChange) ? this.records : records as List<RepoListRecordsRecord>?, 108 + recordsCursor: identical(recordsCursor, _devToolsStateNoChange) ? this.recordsCursor : recordsCursor as String?, 109 + selectedRecord: identical(selectedRecord, _devToolsStateNoChange) ? this.selectedRecord : selectedRecord as RecordInfo?, 110 + errorMessage: identical(errorMessage, _devToolsStateNoChange) ? this.errorMessage : errorMessage as String?, 111 + ); 112 + } 113 + 114 + @override 115 + List<Object?> get props => [ 116 + status, 117 + did, 118 + handle, 119 + repoHandle, 120 + collections, 121 + isCollectionCountsLoading, 122 + selectedCollection, 123 + records, 124 + recordsCursor, 125 + selectedRecord, 126 + errorMessage, 127 + ]; 128 + }
+769
lib/features/devtools/presentation/dev_tools_screen.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto/com_atproto_repo_listrecords.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter/services.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 8 + import 'package:url_launcher/url_launcher.dart'; 9 + 10 + class DevToolsScreen extends StatelessWidget { 11 + const DevToolsScreen({super.key}); 12 + 13 + @override 14 + Widget build(BuildContext context) { 15 + return Scaffold( 16 + appBar: AppBar( 17 + title: const Text('PDS Explorer'), 18 + actions: [ 19 + IconButton( 20 + icon: const Icon(Icons.open_in_new), 21 + tooltip: 'Open pds.ls inspiration', 22 + onPressed: () => _openExternalUrl('https://pds.ls'), 23 + ), 24 + ], 25 + ), 26 + body: BlocBuilder<DevToolsCubit, DevToolsState>( 27 + builder: (context, state) { 28 + return Column( 29 + children: [ 30 + _SearchInput(state: state), 31 + if (state.status == DevToolsStatus.repoLoaded || 32 + state.status == DevToolsStatus.collectionLoaded || 33 + state.status == DevToolsStatus.recordLoaded) 34 + _TabBar(state: state), 35 + Expanded(child: _Content(state: state)), 36 + ], 37 + ); 38 + }, 39 + ), 40 + ); 41 + } 42 + } 43 + 44 + class _SearchInput extends StatefulWidget { 45 + const _SearchInput({required this.state}); 46 + 47 + final DevToolsState state; 48 + 49 + @override 50 + State<_SearchInput> createState() => _SearchInputState(); 51 + } 52 + 53 + class _SearchInputState extends State<_SearchInput> { 54 + late final TextEditingController _controller; 55 + 56 + @override 57 + void initState() { 58 + super.initState(); 59 + _controller = TextEditingController(); 60 + } 61 + 62 + @override 63 + void dispose() { 64 + _controller.dispose(); 65 + super.dispose(); 66 + } 67 + 68 + @override 69 + Widget build(BuildContext context) { 70 + return Padding( 71 + padding: const EdgeInsets.all(16), 72 + child: Row( 73 + children: [ 74 + Expanded( 75 + child: TextField( 76 + controller: _controller, 77 + decoration: const InputDecoration( 78 + hintText: 'Handle, DID, or at:// URI', 79 + border: OutlineInputBorder(), 80 + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), 81 + isDense: true, 82 + ), 83 + style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13), 84 + onSubmitted: _resolve, 85 + ), 86 + ), 87 + const SizedBox(width: 8), 88 + FilledButton( 89 + onPressed: widget.state.isLoading ? null : () => _resolve(_controller.text), 90 + child: const Text('Resolve'), 91 + ), 92 + ], 93 + ), 94 + ); 95 + } 96 + 97 + void _resolve(String value) { 98 + final query = value.trim(); 99 + if (query.isEmpty) { 100 + return; 101 + } 102 + 103 + context.read<DevToolsCubit>().resolve(query); 104 + } 105 + } 106 + 107 + class _TabBar extends StatelessWidget { 108 + const _TabBar({required this.state}); 109 + 110 + final DevToolsState state; 111 + 112 + @override 113 + Widget build(BuildContext context) { 114 + return Container( 115 + decoration: BoxDecoration( 116 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 117 + ), 118 + child: Row( 119 + children: [ 120 + _Tab( 121 + label: 'Repo', 122 + isSelected: state.status == DevToolsStatus.repoLoaded, 123 + onTap: () => context.read<DevToolsCubit>().goBackToRepo(), 124 + ), 125 + if (state.selectedCollection != null) 126 + _Tab( 127 + label: 'Records', 128 + isSelected: state.status == DevToolsStatus.collectionLoaded, 129 + onTap: () => context.read<DevToolsCubit>().goBackToCollection(), 130 + ), 131 + if (state.selectedRecord != null) 132 + _Tab(label: 'JSON', isSelected: state.status == DevToolsStatus.recordLoaded, onTap: () {}), 133 + ], 134 + ), 135 + ); 136 + } 137 + } 138 + 139 + class _Tab extends StatelessWidget { 140 + const _Tab({required this.label, required this.isSelected, required this.onTap}); 141 + 142 + final String label; 143 + final bool isSelected; 144 + final VoidCallback onTap; 145 + 146 + @override 147 + Widget build(BuildContext context) { 148 + return Expanded( 149 + child: InkWell( 150 + onTap: onTap, 151 + child: Container( 152 + padding: const EdgeInsets.symmetric(vertical: 12), 153 + decoration: BoxDecoration( 154 + border: Border( 155 + bottom: BorderSide( 156 + color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent, 157 + width: 2, 158 + ), 159 + ), 160 + ), 161 + child: Text( 162 + label, 163 + textAlign: TextAlign.center, 164 + style: TextStyle( 165 + fontWeight: FontWeight.w600, 166 + color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).textTheme.bodyMedium?.color, 167 + ), 168 + ), 169 + ), 170 + ), 171 + ); 172 + } 173 + } 174 + 175 + class _Content extends StatelessWidget { 176 + const _Content({required this.state}); 177 + 178 + final DevToolsState state; 179 + 180 + @override 181 + Widget build(BuildContext context) { 182 + if (state.isLoading && state.status == DevToolsStatus.loading) { 183 + return const Center(child: CircularProgressIndicator()); 184 + } 185 + 186 + if (state.status == DevToolsStatus.error) { 187 + return Center( 188 + child: Padding( 189 + padding: const EdgeInsets.all(16), 190 + child: Column( 191 + mainAxisSize: MainAxisSize.min, 192 + children: [ 193 + Icon(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), 194 + const SizedBox(height: 16), 195 + Text('Error', style: Theme.of(context).textTheme.titleMedium), 196 + const SizedBox(height: 8), 197 + Text( 198 + state.errorMessage ?? 'Unknown error', 199 + textAlign: TextAlign.center, 200 + style: Theme.of(context).textTheme.bodyMedium, 201 + ), 202 + ], 203 + ), 204 + ), 205 + ); 206 + } 207 + 208 + if (state.status == DevToolsStatus.recordLoaded && state.selectedRecord != null) { 209 + return _RecordInspector(record: state.selectedRecord!); 210 + } 211 + 212 + if (state.status == DevToolsStatus.collectionLoaded && state.selectedCollection != null) { 213 + return _RecordsList(state: state); 214 + } 215 + 216 + if (state.status == DevToolsStatus.repoLoaded && state.did != null) { 217 + return _RepoOverview(state: state); 218 + } 219 + 220 + return const _EmptyState(); 221 + } 222 + } 223 + 224 + class _EmptyState extends StatelessWidget { 225 + const _EmptyState(); 226 + 227 + @override 228 + Widget build(BuildContext context) { 229 + return Center( 230 + child: Padding( 231 + padding: const EdgeInsets.all(24), 232 + child: Column( 233 + mainAxisSize: MainAxisSize.min, 234 + children: [ 235 + Icon(Icons.explore_outlined, size: 64, color: Theme.of(context).colorScheme.outline), 236 + const SizedBox(height: 16), 237 + Text('PDS Explorer', style: Theme.of(context).textTheme.titleMedium), 238 + const SizedBox(height: 8), 239 + Text( 240 + 'Enter a handle, DID, or AT-URI to explore\n' 241 + 'a user\'s repository.', 242 + textAlign: TextAlign.center, 243 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), 244 + ), 245 + const SizedBox(height: 16), 246 + TextButton.icon( 247 + onPressed: () => _openExternalUrl('https://pds.ls'), 248 + icon: const Icon(Icons.open_in_new, size: 16), 249 + label: const Text('Inspired by pds.ls'), 250 + ), 251 + ], 252 + ), 253 + ), 254 + ); 255 + } 256 + } 257 + 258 + class _RepoOverview extends StatelessWidget { 259 + const _RepoOverview({required this.state}); 260 + 261 + final DevToolsState state; 262 + 263 + @override 264 + Widget build(BuildContext context) { 265 + final totalRepoRecords = state.totalRepoRecords; 266 + 267 + return ListView( 268 + children: [ 269 + Container( 270 + padding: const EdgeInsets.all(16), 271 + decoration: BoxDecoration( 272 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 273 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 274 + ), 275 + child: Column( 276 + crossAxisAlignment: CrossAxisAlignment.start, 277 + children: [ 278 + Row( 279 + children: [ 280 + CircleAvatar(child: Text(_initialFor(state.repoHandle ?? state.handle))), 281 + const SizedBox(width: 12), 282 + Expanded( 283 + child: Column( 284 + crossAxisAlignment: CrossAxisAlignment.start, 285 + children: [ 286 + Text( 287 + state.repoHandle ?? state.handle ?? 'Unknown', 288 + style: Theme.of(context).textTheme.titleMedium, 289 + ), 290 + const SizedBox(height: 2), 291 + Text( 292 + state.did ?? '', 293 + style: Theme.of(context).textTheme.bodySmall?.copyWith( 294 + fontFamily: 'JetBrains Mono', 295 + color: Theme.of(context).colorScheme.outline, 296 + ), 297 + overflow: TextOverflow.ellipsis, 298 + ), 299 + ], 300 + ), 301 + ), 302 + ], 303 + ), 304 + const SizedBox(height: 12), 305 + Wrap( 306 + spacing: 16, 307 + runSpacing: 4, 308 + children: [ 309 + Text('${state.collections.length} collections', style: Theme.of(context).textTheme.bodySmall), 310 + Text( 311 + totalRepoRecords == null 312 + ? (state.isCollectionCountsLoading ? 'Counting records...' : 'Record counts unavailable') 313 + : '$totalRepoRecords records', 314 + style: Theme.of(context).textTheme.bodySmall, 315 + ), 316 + ], 317 + ), 318 + ], 319 + ), 320 + ), 321 + Padding( 322 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), 323 + child: Text( 324 + 'COLLECTIONS', 325 + style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 326 + ), 327 + ), 328 + ...state.collections.map((collection) => _CollectionItem(collection: collection)), 329 + ], 330 + ); 331 + } 332 + } 333 + 334 + class _CollectionItem extends StatelessWidget { 335 + const _CollectionItem({required this.collection}); 336 + 337 + final CollectionSummary collection; 338 + 339 + @override 340 + Widget build(BuildContext context) { 341 + return ListTile( 342 + leading: Container( 343 + width: 32, 344 + height: 32, 345 + decoration: BoxDecoration( 346 + borderRadius: BorderRadius.circular(6), 347 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 348 + ), 349 + child: Icon(_getCollectionIcon(collection.name), size: 16, color: Theme.of(context).colorScheme.outline), 350 + ), 351 + title: Text(collection.name, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13)), 352 + trailing: Row( 353 + mainAxisSize: MainAxisSize.min, 354 + children: [ 355 + Container( 356 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 357 + decoration: BoxDecoration( 358 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 359 + borderRadius: BorderRadius.circular(999), 360 + ), 361 + child: Text(collection.countLabel, style: Theme.of(context).textTheme.labelSmall), 362 + ), 363 + const SizedBox(width: 4), 364 + const Icon(Icons.chevron_right), 365 + ], 366 + ), 367 + onTap: () => context.read<DevToolsCubit>().loadCollection(collection.name), 368 + ); 369 + } 370 + 371 + /// NOTE: repost must come before post 372 + IconData _getCollectionIcon(String collection) { 373 + if (collection.contains('repost')) return Icons.repeat; 374 + if (collection.contains('post')) return Icons.chat_bubble_outline; 375 + if (collection.contains('like')) return Icons.favorite_outline; 376 + if (collection.contains('follow')) return Icons.person_add_outlined; 377 + if (collection.contains('profile')) return Icons.person_outline; 378 + if (collection.contains('generator')) return Icons.auto_awesome_outlined; 379 + if (collection.contains('list')) return Icons.list_outlined; 380 + if (collection.contains('block')) return Icons.block; 381 + return Icons.folder_outlined; 382 + } 383 + } 384 + 385 + class _RecordsList extends StatefulWidget { 386 + const _RecordsList({required this.state}); 387 + 388 + final DevToolsState state; 389 + 390 + @override 391 + State<_RecordsList> createState() => _RecordsListState(); 392 + } 393 + 394 + class _RecordsListState extends State<_RecordsList> { 395 + final _scrollController = ScrollController(); 396 + 397 + @override 398 + void initState() { 399 + super.initState(); 400 + _scrollController.addListener(_onScroll); 401 + } 402 + 403 + @override 404 + void dispose() { 405 + _scrollController.dispose(); 406 + super.dispose(); 407 + } 408 + 409 + void _onScroll() { 410 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 411 + if (widget.state.hasMoreRecords && !widget.state.isLoading) { 412 + context.read<DevToolsCubit>().loadMoreRecords(); 413 + } 414 + } 415 + } 416 + 417 + @override 418 + Widget build(BuildContext context) { 419 + final records = widget.state.records ?? []; 420 + final selectedCollection = _collectionSummary(widget.state, widget.state.selectedCollection); 421 + 422 + return Column( 423 + crossAxisAlignment: CrossAxisAlignment.start, 424 + children: [ 425 + Container( 426 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 427 + decoration: BoxDecoration( 428 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 429 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 430 + ), 431 + child: Row( 432 + children: [ 433 + Expanded( 434 + child: Text( 435 + widget.state.selectedCollection ?? '', 436 + style: Theme.of(context).textTheme.titleSmall?.copyWith( 437 + fontFamily: 'JetBrains Mono', 438 + color: Theme.of(context).colorScheme.primary, 439 + ), 440 + ), 441 + ), 442 + Text( 443 + selectedCollection?.recordCount == null 444 + ? '${records.length} loaded' 445 + : '${records.length} of ${selectedCollection!.recordCount}', 446 + style: Theme.of(context).textTheme.bodySmall, 447 + ), 448 + ], 449 + ), 450 + ), 451 + Expanded( 452 + child: ListView.builder( 453 + controller: _scrollController, 454 + itemCount: records.length + (widget.state.isLoading ? 1 : 0), 455 + itemBuilder: (context, index) { 456 + if (index == records.length) { 457 + return const Padding( 458 + padding: EdgeInsets.all(16), 459 + child: Center( 460 + child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), 461 + ), 462 + ); 463 + } 464 + 465 + final record = records[index]; 466 + return _RecordItem(record: record); 467 + }, 468 + ), 469 + ), 470 + ], 471 + ); 472 + } 473 + } 474 + 475 + class _RecordItem extends StatelessWidget { 476 + const _RecordItem({required this.record}); 477 + 478 + final RepoListRecordsRecord record; 479 + 480 + @override 481 + Widget build(BuildContext context) { 482 + final rkey = record.uri.rkey; 483 + final preview = _getRecordPreview(record.value); 484 + 485 + return ListTile( 486 + title: Text(rkey, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13)), 487 + subtitle: preview != null ? Text(preview, maxLines: 1, overflow: TextOverflow.ellipsis) : null, 488 + trailing: const Icon(Icons.chevron_right), 489 + onTap: () => context.read<DevToolsCubit>().loadRecord(record), 490 + ); 491 + } 492 + 493 + String? _getRecordPreview(Map<String, dynamic> value) { 494 + final text = value['text']; 495 + if (text is String && text.isNotEmpty) { 496 + return text.length > 50 ? '${text.substring(0, 50)}...' : text; 497 + } 498 + 499 + final displayName = value['displayName']; 500 + if (displayName is String && displayName.isNotEmpty) { 501 + return displayName; 502 + } 503 + 504 + return null; 505 + } 506 + } 507 + 508 + class _RecordInspector extends StatelessWidget { 509 + const _RecordInspector({required this.record}); 510 + 511 + final RecordInfo record; 512 + 513 + @override 514 + Widget build(BuildContext context) { 515 + final jsonString = const JsonEncoder.withIndent(' ').convert(record.value); 516 + 517 + return Column( 518 + children: [ 519 + Container( 520 + width: double.infinity, 521 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 522 + decoration: BoxDecoration( 523 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 524 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 525 + ), 526 + child: Column( 527 + crossAxisAlignment: CrossAxisAlignment.start, 528 + children: [ 529 + Text( 530 + record.rkey, 531 + style: Theme.of(context).textTheme.titleSmall?.copyWith( 532 + fontFamily: 'JetBrains Mono', 533 + color: Theme.of(context).colorScheme.primary, 534 + ), 535 + overflow: TextOverflow.ellipsis, 536 + ), 537 + const SizedBox(height: 4), 538 + Text( 539 + record.uri, 540 + style: Theme.of(context).textTheme.bodySmall?.copyWith( 541 + fontFamily: 'JetBrains Mono', 542 + color: Theme.of(context).colorScheme.outline, 543 + ), 544 + overflow: TextOverflow.ellipsis, 545 + ), 546 + if (record.cid != null) ...[ 547 + const SizedBox(height: 2), 548 + Text( 549 + 'CID: ${record.cid!}', 550 + style: Theme.of(context).textTheme.bodySmall?.copyWith( 551 + fontFamily: 'JetBrains Mono', 552 + color: Theme.of(context).colorScheme.outline, 553 + ), 554 + ), 555 + ], 556 + const SizedBox(height: 8), 557 + Wrap( 558 + spacing: 8, 559 + runSpacing: 8, 560 + children: [ 561 + TextButton.icon( 562 + icon: const Icon(Icons.copy, size: 16), 563 + label: const Text('Copy'), 564 + onPressed: () { 565 + Clipboard.setData(ClipboardData(text: jsonString)); 566 + ScaffoldMessenger.of(context).showSnackBar( 567 + const SnackBar(content: Text('JSON copied to clipboard'), behavior: SnackBarBehavior.floating), 568 + ); 569 + }, 570 + ), 571 + TextButton.icon( 572 + icon: const Icon(Icons.open_in_new, size: 16), 573 + label: const Text('aturi.to'), 574 + onPressed: () => _openExternalUrl(record.atUriToLink), 575 + ), 576 + ], 577 + ), 578 + ], 579 + ), 580 + ), 581 + Expanded( 582 + child: SingleChildScrollView( 583 + padding: const EdgeInsets.all(16), 584 + child: _JsonViewer(json: record.value), 585 + ), 586 + ), 587 + ], 588 + ); 589 + } 590 + } 591 + 592 + class _JsonViewer extends StatelessWidget { 593 + const _JsonViewer({required this.json}); 594 + 595 + final Map<String, dynamic> json; 596 + 597 + @override 598 + Widget build(BuildContext context) { 599 + return SelectableText.rich( 600 + TextSpan(children: _buildSpans(context, json, 0)), 601 + style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, height: 1.8), 602 + ); 603 + } 604 + 605 + List<TextSpan> _buildSpans(BuildContext context, dynamic value, int indent) { 606 + final theme = Theme.of(context); 607 + final primaryColor = theme.colorScheme.primary; 608 + final surfaceVariant = theme.colorScheme.onSurfaceVariant; 609 + 610 + if (value is Map<String, dynamic>) { 611 + final spans = <TextSpan>[ 612 + TextSpan( 613 + text: '{\n', 614 + style: TextStyle(color: surfaceVariant), 615 + ), 616 + ]; 617 + final entries = value.entries.toList(); 618 + for (var i = 0; i < entries.length; i++) { 619 + final entry = entries[i]; 620 + spans.add( 621 + TextSpan( 622 + text: ' ' * (indent + 1), 623 + style: TextStyle(color: surfaceVariant), 624 + ), 625 + ); 626 + spans.add( 627 + TextSpan( 628 + text: '"${entry.key}"', 629 + style: TextStyle(color: primaryColor), 630 + ), 631 + ); 632 + spans.add( 633 + TextSpan( 634 + text: ': ', 635 + style: TextStyle(color: surfaceVariant), 636 + ), 637 + ); 638 + spans.addAll(_buildSpans(context, entry.value, indent + 1)); 639 + if (i < entries.length - 1) { 640 + spans.add( 641 + TextSpan( 642 + text: ',', 643 + style: TextStyle(color: surfaceVariant), 644 + ), 645 + ); 646 + } 647 + spans.add( 648 + TextSpan( 649 + text: '\n', 650 + style: TextStyle(color: surfaceVariant), 651 + ), 652 + ); 653 + } 654 + spans.add( 655 + TextSpan( 656 + text: ' ' * indent + '}', 657 + style: TextStyle(color: surfaceVariant), 658 + ), 659 + ); 660 + return spans; 661 + } 662 + 663 + if (value is List) { 664 + final spans = <TextSpan>[ 665 + TextSpan( 666 + text: '[\n', 667 + style: TextStyle(color: surfaceVariant), 668 + ), 669 + ]; 670 + for (var i = 0; i < value.length; i++) { 671 + spans.add( 672 + TextSpan( 673 + text: ' ' * (indent + 1), 674 + style: TextStyle(color: surfaceVariant), 675 + ), 676 + ); 677 + spans.addAll(_buildSpans(context, value[i], indent + 1)); 678 + if (i < value.length - 1) { 679 + spans.add( 680 + TextSpan( 681 + text: ',', 682 + style: TextStyle(color: surfaceVariant), 683 + ), 684 + ); 685 + } 686 + spans.add( 687 + TextSpan( 688 + text: '\n', 689 + style: TextStyle(color: surfaceVariant), 690 + ), 691 + ); 692 + } 693 + spans.add( 694 + TextSpan( 695 + text: ' ' * indent + ']', 696 + style: TextStyle(color: surfaceVariant), 697 + ), 698 + ); 699 + return spans; 700 + } 701 + 702 + if (value is String) { 703 + return [ 704 + TextSpan( 705 + text: '"$value"', 706 + style: TextStyle(color: theme.colorScheme.primaryContainer), 707 + ), 708 + ]; 709 + } 710 + 711 + if (value is num) { 712 + return [ 713 + TextSpan( 714 + text: value.toString(), 715 + style: TextStyle(color: theme.colorScheme.tertiary), 716 + ), 717 + ]; 718 + } 719 + 720 + if (value is bool) { 721 + return [ 722 + TextSpan( 723 + text: value.toString(), 724 + style: TextStyle(color: theme.colorScheme.secondary), 725 + ), 726 + ]; 727 + } 728 + 729 + if (value == null) { 730 + return [ 731 + TextSpan( 732 + text: 'null', 733 + style: TextStyle(color: surfaceVariant), 734 + ), 735 + ]; 736 + } 737 + 738 + return [TextSpan(text: value.toString())]; 739 + } 740 + } 741 + 742 + CollectionSummary? _collectionSummary(DevToolsState state, String? collectionName) { 743 + if (collectionName == null) { 744 + return null; 745 + } 746 + 747 + for (final collection in state.collections) { 748 + if (collection.name == collectionName) { 749 + return collection; 750 + } 751 + } 752 + 753 + return null; 754 + } 755 + 756 + String _initialFor(String? value) { 757 + if (value == null || value.isEmpty) { 758 + return '?'; 759 + } 760 + 761 + return value.substring(0, 1).toUpperCase(); 762 + } 763 + 764 + Future<void> _openExternalUrl(String value) async { 765 + final uri = Uri.parse(value); 766 + if (await canLaunchUrl(uri)) { 767 + await launchUrl(uri, mode: LaunchMode.externalApplication); 768 + } 769 + }
+6 -1
lib/features/settings/presentation/settings_screen.dart
··· 69 69 ), 70 70 const SizedBox(height: 24), 71 71 _buildSectionHeader(context, 'About'), 72 - _SettingsTile(icon: Icons.code_outlined, title: 'Dev Tools', subtitle: 'PDS Explorer', onTap: () {}), 72 + _SettingsTile( 73 + icon: Icons.code_outlined, 74 + title: 'Dev Tools', 75 + subtitle: 'PDS Explorer', 76 + onTap: () => context.push('/settings/devtools'), 77 + ), 73 78 _SettingsTile( 74 79 icon: Icons.description_outlined, 75 80 title: 'Logs',
+3 -1
lib/main.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:lazurite/core/database/app_database.dart'; 5 5 import 'package:lazurite/core/logging/app_logger.dart'; 6 - import 'package:lazurite/core/network/xrpc_client_factory.dart'; 7 6 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 8 7 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 8 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 9 9 import 'package:lazurite/core/router/app_router.dart'; 10 10 import 'package:lazurite/core/theme/app_theme.dart'; 11 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 12 import 'package:lazurite/features/auth/data/auth_repository.dart'; 13 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 13 14 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 14 15 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 15 16 import 'package:lazurite/features/feed/data/feed_repository.dart'; ··· 109 110 FeedPreferencesCubit(feedRepository: feedRepository, database: database, accountDid: accountDid) 110 111 ..loadPreferences(), 111 112 ), 113 + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 112 114 RepositoryProvider.value(value: feedRepository), 113 115 ], 114 116 child: appShell,
+306
test/features/devtools/cubit/dev_tools_cubit_test.dart
··· 1 + import 'package:atproto/com_atproto_identity_resolvehandle.dart'; 2 + import 'package:atproto/com_atproto_repo_describerepo.dart'; 3 + import 'package:atproto/com_atproto_repo_getrecord.dart'; 4 + import 'package:atproto/com_atproto_repo_listrecords.dart'; 5 + import 'package:atproto_core/atproto_core.dart'; 6 + import 'package:bloc_test/bloc_test.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 9 + 10 + class FakeDevToolsRepository implements DevToolsRepository { 11 + FakeDevToolsRepository({ 12 + this.resolveHandleHandler, 13 + this.describeRepoHandler, 14 + this.listRecordsHandler, 15 + this.getRecordHandler, 16 + }); 17 + 18 + Future<IdentityResolveHandleOutput> Function({required String handle})? resolveHandleHandler; 19 + Future<RepoDescribeRepoOutput> Function({required String repo})? describeRepoHandler; 20 + Future<RepoListRecordsOutput> Function({ 21 + required String repo, 22 + required String collection, 23 + int? limit, 24 + String? cursor, 25 + bool? reverse, 26 + })? 27 + listRecordsHandler; 28 + Future<RepoGetRecordOutput> Function({required String repo, required String collection, required String rkey})? 29 + getRecordHandler; 30 + 31 + @override 32 + Future<RepoDescribeRepoOutput> describeRepo({required String repo}) { 33 + return describeRepoHandler!.call(repo: repo); 34 + } 35 + 36 + @override 37 + Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}) { 38 + return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey); 39 + } 40 + 41 + @override 42 + Future<RepoListRecordsOutput> listRecords({ 43 + required String repo, 44 + required String collection, 45 + int? limit, 46 + String? cursor, 47 + bool? reverse, 48 + }) { 49 + return listRecordsHandler!.call(repo: repo, collection: collection, limit: limit, cursor: cursor, reverse: reverse); 50 + } 51 + 52 + @override 53 + Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) { 54 + return resolveHandleHandler!.call(handle: handle); 55 + } 56 + } 57 + 58 + void main() { 59 + group('DevToolsCubit', () { 60 + test('initial state is DevToolsState with initial status', () { 61 + final cubit = DevToolsCubit(repository: FakeDevToolsRepository()); 62 + expect(cubit.state, const DevToolsState()); 63 + }); 64 + 65 + blocTest<DevToolsCubit, DevToolsState>( 66 + 'clearInput resets to the initial state', 67 + build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 68 + seed: () => const DevToolsState(status: DevToolsStatus.repoLoaded, did: 'did:plc:test'), 69 + act: (cubit) => cubit.clearInput(), 70 + expect: () => [const DevToolsState()], 71 + ); 72 + 73 + blocTest<DevToolsCubit, DevToolsState>( 74 + 'resolve handle loads repo and progressive collection counts', 75 + build: () { 76 + final repository = FakeDevToolsRepository( 77 + resolveHandleHandler: ({required String handle}) async => 78 + const IdentityResolveHandleOutput(did: 'did:plc:alice'), 79 + describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput( 80 + handle: 'alice.bsky.social', 81 + did: 'did:plc:alice', 82 + didDoc: {}, 83 + collections: ['app.bsky.feed.post'], 84 + handleIsCorrect: true, 85 + ), 86 + listRecordsHandler: 87 + ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 88 + expect(repo, 'did:plc:alice'); 89 + expect(collection, 'app.bsky.feed.post'); 90 + return RepoListRecordsOutput( 91 + cursor: cursor == null ? 'next' : null, 92 + records: [ 93 + RepoListRecordsRecord( 94 + uri: AtUri('at://did:plc:alice/app.bsky.feed.post/${cursor == null ? '1' : '2'}'), 95 + cid: 'cid-${cursor ?? 'first'}', 96 + value: {'text': 'post ${cursor ?? 'first'}'}, 97 + ), 98 + ], 99 + ); 100 + }, 101 + ); 102 + 103 + return DevToolsCubit(repository: repository); 104 + }, 105 + act: (cubit) => cubit.resolve('alice.bsky.social'), 106 + wait: const Duration(milliseconds: 10), 107 + expect: () => [ 108 + const DevToolsState(status: DevToolsStatus.loading), 109 + isA<DevToolsState>() 110 + .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 111 + .having((state) => state.did, 'did', 'did:plc:alice') 112 + .having((state) => state.repoHandle, 'repoHandle', 'alice.bsky.social') 113 + .having((state) => state.collections.first.recordCount, 'initial count', isNull) 114 + .having((state) => state.isCollectionCountsLoading, 'count loading', isTrue), 115 + isA<DevToolsState>() 116 + .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 117 + .having((state) => state.collections.first.recordCount, 'resolved count', 2) 118 + .having((state) => state.totalRepoRecords, 'total repo records', 2) 119 + .having((state) => state.isCollectionCountsLoading, 'count loading', isFalse), 120 + ], 121 + ); 122 + 123 + blocTest<DevToolsCubit, DevToolsState>( 124 + 'resolve AT-URI loads collection and full record JSON', 125 + build: () { 126 + final repository = FakeDevToolsRepository( 127 + resolveHandleHandler: ({required String handle}) async => 128 + const IdentityResolveHandleOutput(did: 'did:plc:alice'), 129 + describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput( 130 + handle: 'alice.bsky.social', 131 + did: 'did:plc:alice', 132 + didDoc: {}, 133 + collections: ['app.bsky.feed.post'], 134 + handleIsCorrect: true, 135 + ), 136 + listRecordsHandler: 137 + ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 138 + if (limit == 100) { 139 + return const RepoListRecordsOutput( 140 + records: [ 141 + RepoListRecordsRecord( 142 + uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 143 + cid: 'cid-count', 144 + value: {'text': 'count me'}, 145 + ), 146 + ], 147 + ); 148 + } 149 + 150 + return const RepoListRecordsOutput( 151 + records: [ 152 + RepoListRecordsRecord( 153 + uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 154 + cid: 'cid-list', 155 + value: {'text': 'summary'}, 156 + ), 157 + ], 158 + ); 159 + }, 160 + getRecordHandler: ({required String repo, required String collection, required String rkey}) async { 161 + expect(repo, 'did:plc:alice'); 162 + expect(collection, 'app.bsky.feed.post'); 163 + expect(rkey, '3kz'); 164 + return const RepoGetRecordOutput( 165 + uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 166 + cid: 'cid-full', 167 + value: { 168 + 'text': 'full record', 169 + 'nested': {'ok': true}, 170 + }, 171 + ); 172 + }, 173 + ); 174 + 175 + return DevToolsCubit(repository: repository); 176 + }, 177 + act: (cubit) => cubit.resolve('at://alice.bsky.social/app.bsky.feed.post/3kz'), 178 + wait: const Duration(milliseconds: 10), 179 + expect: () => [ 180 + const DevToolsState(status: DevToolsStatus.loading), 181 + isA<DevToolsState>() 182 + .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) 183 + .having((state) => state.did, 'did', 'did:plc:alice') 184 + .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post') 185 + .having((state) => state.records?.length, 'list records', 1) 186 + .having((state) => state.selectedRecord?.cid, 'full cid', 'cid-full') 187 + .having((state) => state.selectedRecord?.value['nested'], 'nested JSON', {'ok': true}), 188 + isA<DevToolsState>() 189 + .having((state) => state.collections.first.recordCount, 'collection count', 1) 190 + .having((state) => state.totalRepoRecords, 'total repo records', 1), 191 + ], 192 + ); 193 + 194 + blocTest<DevToolsCubit, DevToolsState>( 195 + 'loadRecord replaces list preview with getRecord response', 196 + build: () { 197 + final repository = FakeDevToolsRepository( 198 + getRecordHandler: ({required String repo, required String collection, required String rkey}) async { 199 + return const RepoGetRecordOutput( 200 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 201 + cid: 'cid123', 202 + value: { 203 + 'text': 'Expanded', 204 + 'reply': {'root': 'abc'}, 205 + }, 206 + ); 207 + }, 208 + ); 209 + 210 + return DevToolsCubit(repository: repository); 211 + }, 212 + seed: () => const DevToolsState( 213 + status: DevToolsStatus.collectionLoaded, 214 + did: 'did:plc:test', 215 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 216 + selectedCollection: 'app.bsky.feed.post', 217 + records: [ 218 + RepoListRecordsRecord( 219 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 220 + cid: 'cid-list', 221 + value: {'text': 'Summary'}, 222 + ), 223 + ], 224 + ), 225 + act: (cubit) => cubit.loadRecord( 226 + const RepoListRecordsRecord( 227 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 228 + cid: 'cid-list', 229 + value: {'text': 'Summary'}, 230 + ), 231 + ), 232 + expect: () => [ 233 + isA<DevToolsState>().having((state) => state.status, 'status', DevToolsStatus.loading), 234 + isA<DevToolsState>() 235 + .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) 236 + .having((state) => state.selectedRecord?.cid, 'cid', 'cid123') 237 + .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}), 238 + ], 239 + ); 240 + 241 + blocTest<DevToolsCubit, DevToolsState>( 242 + 'invalid AT-URI surfaces a clear error', 243 + build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 244 + act: (cubit) => cubit.resolve('at://bad uri'), 245 + expect: () => [ 246 + const DevToolsState(status: DevToolsStatus.loading), 247 + isA<DevToolsState>() 248 + .having((state) => state.status, 'status', DevToolsStatus.error) 249 + .having((state) => state.errorMessage, 'error', 'Invalid AT-URI'), 250 + ], 251 + ); 252 + 253 + blocTest<DevToolsCubit, DevToolsState>( 254 + 'goBackToCollection clears selectedRecord', 255 + build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 256 + seed: () => const DevToolsState( 257 + status: DevToolsStatus.recordLoaded, 258 + did: 'did:plc:test', 259 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 260 + selectedCollection: 'app.bsky.feed.post', 261 + records: [ 262 + RepoListRecordsRecord( 263 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 264 + cid: 'cid', 265 + value: {'text': 'Summary'}, 266 + ), 267 + ], 268 + selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}), 269 + ), 270 + act: (cubit) => cubit.goBackToCollection(), 271 + expect: () => [ 272 + isA<DevToolsState>() 273 + .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 274 + .having((state) => state.selectedRecord, 'selectedRecord', isNull) 275 + .having((state) => state.records?.length, 'records length', 1), 276 + ], 277 + ); 278 + 279 + blocTest<DevToolsCubit, DevToolsState>( 280 + 'goBackToRepo clears collection and record state', 281 + build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 282 + seed: () => const DevToolsState( 283 + status: DevToolsStatus.recordLoaded, 284 + did: 'did:plc:test', 285 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 286 + selectedCollection: 'app.bsky.feed.post', 287 + records: [ 288 + RepoListRecordsRecord( 289 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 290 + cid: 'cid', 291 + value: {'text': 'Summary'}, 292 + ), 293 + ], 294 + selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}), 295 + ), 296 + act: (cubit) => cubit.goBackToRepo(), 297 + expect: () => [ 298 + isA<DevToolsState>() 299 + .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 300 + .having((state) => state.selectedCollection, 'selectedCollection', isNull) 301 + .having((state) => state.records, 'records', isNull) 302 + .having((state) => state.selectedRecord, 'selectedRecord', isNull), 303 + ], 304 + ); 305 + }); 306 + }
+194
test/features/devtools/cubit/dev_tools_state_test.dart
··· 1 + import 'package:atproto/com_atproto_repo_listrecords.dart'; 2 + import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 5 + 6 + void main() { 7 + group('CollectionSummary', () { 8 + test('countLabel shows placeholder until count is known', () { 9 + expect(const CollectionSummary('app.bsky.feed.post').countLabel, '...'); 10 + expect(const CollectionSummary('app.bsky.feed.post', recordCount: 12).countLabel, '12'); 11 + }); 12 + }); 13 + 14 + group('RecordInfo', () { 15 + test('creates with required parameters', () { 16 + const record = RecordInfo( 17 + uri: 'at://did:plc:test/app.bsky.feed.post/abc123', 18 + cid: 'bafyrei123', 19 + value: {'text': 'Hello'}, 20 + ); 21 + 22 + expect(record.uri, 'at://did:plc:test/app.bsky.feed.post/abc123'); 23 + expect(record.cid, 'bafyrei123'); 24 + expect(record.value, {'text': 'Hello'}); 25 + }); 26 + 27 + test('rkey extracts last path segment', () { 28 + const record = RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/abc123', value: {}); 29 + expect(record.rkey, 'abc123'); 30 + }); 31 + 32 + test('rkey returns empty string for invalid URI', () { 33 + const record = RecordInfo(uri: 'invalid', value: {}); 34 + expect(record.rkey, ''); 35 + }); 36 + 37 + test('atUriToLink constructs aturi.to URL', () { 38 + const record = RecordInfo( 39 + uri: 'at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3m6mwoadjbp2d', 40 + value: {}, 41 + ); 42 + 43 + expect(record.atUriToLink, 'https://aturi.to/did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3m6mwoadjbp2d'); 44 + }); 45 + }); 46 + 47 + group('DevToolsState', () { 48 + test('initial state has default values', () { 49 + const state = DevToolsState(); 50 + 51 + expect(state.status, DevToolsStatus.initial); 52 + expect(state.did, isNull); 53 + expect(state.handle, isNull); 54 + expect(state.repoHandle, isNull); 55 + expect(state.collections, isEmpty); 56 + expect(state.isCollectionCountsLoading, isFalse); 57 + expect(state.selectedCollection, isNull); 58 + expect(state.records, isNull); 59 + expect(state.recordsCursor, isNull); 60 + expect(state.selectedRecord, isNull); 61 + expect(state.errorMessage, isNull); 62 + }); 63 + 64 + test('isLoading returns true for loading statuses', () { 65 + expect(const DevToolsState(status: DevToolsStatus.loading).isLoading, isTrue); 66 + expect(const DevToolsState(status: DevToolsStatus.loadingMore).isLoading, isTrue); 67 + expect(const DevToolsState(status: DevToolsStatus.initial).isLoading, isFalse); 68 + expect(const DevToolsState(status: DevToolsStatus.repoLoaded).isLoading, isFalse); 69 + }); 70 + 71 + test('hasMoreRecords returns true when cursor exists', () { 72 + expect(const DevToolsState(recordsCursor: 'cursor123').hasMoreRecords, isTrue); 73 + expect(const DevToolsState(recordsCursor: '').hasMoreRecords, isFalse); 74 + expect(const DevToolsState().hasMoreRecords, isFalse); 75 + }); 76 + 77 + test('totalRecords returns loaded records length', () { 78 + final records = [ 79 + const RepoListRecordsRecord( 80 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/1'), 81 + cid: 'cid1', 82 + value: {'text': 'test'}, 83 + ), 84 + const RepoListRecordsRecord( 85 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/2'), 86 + cid: 'cid2', 87 + value: {'text': 'test2'}, 88 + ), 89 + ]; 90 + 91 + expect(DevToolsState(records: records).totalRecords, 2); 92 + expect(const DevToolsState().totalRecords, 0); 93 + }); 94 + 95 + test('totalRepoRecords returns null until all counts are known', () { 96 + const state = DevToolsState( 97 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 2), CollectionSummary('app.bsky.feed.like')], 98 + ); 99 + 100 + expect(state.totalRepoRecords, isNull); 101 + expect( 102 + const DevToolsState( 103 + collections: [ 104 + CollectionSummary('app.bsky.feed.post', recordCount: 2), 105 + CollectionSummary('app.bsky.feed.like', recordCount: 3), 106 + ], 107 + ).totalRepoRecords, 108 + 5, 109 + ); 110 + }); 111 + 112 + test('copyWith preserves unspecified values', () { 113 + const state = DevToolsState( 114 + status: DevToolsStatus.repoLoaded, 115 + did: 'did:plc:test', 116 + handle: 'test.bsky.social', 117 + repoHandle: 'test.bsky.social', 118 + collections: [CollectionSummary('app.bsky.feed.post')], 119 + ); 120 + 121 + final copied = state.copyWith(selectedCollection: 'app.bsky.feed.post'); 122 + 123 + expect(copied.status, DevToolsStatus.repoLoaded); 124 + expect(copied.did, 'did:plc:test'); 125 + expect(copied.selectedCollection, 'app.bsky.feed.post'); 126 + }); 127 + 128 + test('copyWith can clear nullable fields', () { 129 + final records = [ 130 + const RepoListRecordsRecord( 131 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/1'), 132 + cid: 'cid1', 133 + value: {'text': 'test'}, 134 + ), 135 + ]; 136 + 137 + final state = DevToolsState( 138 + status: DevToolsStatus.recordLoaded, 139 + did: 'did:plc:test', 140 + handle: 'test.bsky.social', 141 + repoHandle: 'test.bsky.social', 142 + collections: const [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 143 + isCollectionCountsLoading: true, 144 + selectedCollection: 'app.bsky.feed.post', 145 + records: records, 146 + recordsCursor: 'cursor', 147 + selectedRecord: const RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/1', value: {'text': 'full'}), 148 + errorMessage: 'error', 149 + ); 150 + 151 + final updated = state.copyWith( 152 + did: null, 153 + handle: null, 154 + repoHandle: null, 155 + isCollectionCountsLoading: false, 156 + selectedCollection: null, 157 + records: null, 158 + recordsCursor: null, 159 + selectedRecord: null, 160 + errorMessage: null, 161 + ); 162 + 163 + expect(updated.did, isNull); 164 + expect(updated.handle, isNull); 165 + expect(updated.repoHandle, isNull); 166 + expect(updated.isCollectionCountsLoading, isFalse); 167 + expect(updated.selectedCollection, isNull); 168 + expect(updated.records, isNull); 169 + expect(updated.recordsCursor, isNull); 170 + expect(updated.selectedRecord, isNull); 171 + expect(updated.errorMessage, isNull); 172 + }); 173 + 174 + test('props includes all fields for equality', () { 175 + const state = DevToolsState( 176 + status: DevToolsStatus.repoLoaded, 177 + did: 'did:plc:test', 178 + handle: 'test.bsky.social', 179 + repoHandle: 'test.bsky.social', 180 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 181 + isCollectionCountsLoading: true, 182 + selectedCollection: 'app.bsky.feed.post', 183 + recordsCursor: 'cursor', 184 + selectedRecord: RecordInfo(uri: 'at://test', value: {}), 185 + errorMessage: 'error', 186 + ); 187 + 188 + expect(state.props.length, 11); 189 + expect(state.props, contains(DevToolsStatus.repoLoaded)); 190 + expect(state.props, contains(true)); 191 + expect(state.props, contains('did:plc:test')); 192 + }); 193 + }); 194 + }
+147
test/features/devtools/presentation/dev_tools_screen_test.dart
··· 1 + import 'package:atproto/com_atproto_repo_listrecords.dart'; 2 + import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 8 + import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockDevToolsCubit extends MockCubit<DevToolsState> implements DevToolsCubit {} 12 + 13 + class FakeDevToolsState extends Fake implements DevToolsState {} 14 + 15 + late MockDevToolsCubit mockDevToolsCubit; 16 + 17 + void main() { 18 + setUpAll(() { 19 + registerFallbackValue(FakeDevToolsState()); 20 + registerFallbackValue( 21 + const RepoListRecordsRecord( 22 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 23 + cid: 'cid123', 24 + value: {'text': 'Test'}, 25 + ), 26 + ); 27 + }); 28 + 29 + setUp(() { 30 + mockDevToolsCubit = MockDevToolsCubit(); 31 + 32 + when(() => mockDevToolsCubit.state).thenReturn(const DevToolsState()); 33 + when(() => mockDevToolsCubit.resolve(any())).thenAnswer((_) async {}); 34 + when(() => mockDevToolsCubit.loadCollection(any())).thenAnswer((_) async {}); 35 + when(() => mockDevToolsCubit.loadRecord(any())).thenAnswer((_) async {}); 36 + when(() => mockDevToolsCubit.loadMoreRecords()).thenAnswer((_) async {}); 37 + when(() => mockDevToolsCubit.goBackToRepo()).thenReturn(null); 38 + when(() => mockDevToolsCubit.goBackToCollection()).thenReturn(null); 39 + when(() => mockDevToolsCubit.clearInput()).thenReturn(null); 40 + 41 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: const DevToolsState()); 42 + }); 43 + 44 + Widget buildSubject() { 45 + return MaterialApp( 46 + home: BlocProvider<DevToolsCubit>.value(value: mockDevToolsCubit, child: const DevToolsScreen()), 47 + ); 48 + } 49 + 50 + group('DevToolsScreen', () { 51 + testWidgets('renders empty state initially', (tester) async { 52 + await tester.pumpWidget(buildSubject()); 53 + 54 + expect(find.text('PDS Explorer'), findsAtLeastNWidgets(1)); 55 + expect(find.textContaining('Enter a handle, DID, or AT-URI'), findsOneWidget); 56 + expect(find.text('Inspired by pds.ls'), findsOneWidget); 57 + }); 58 + 59 + testWidgets('renders loading state', (tester) async { 60 + when(() => mockDevToolsCubit.state).thenReturn(const DevToolsState(status: DevToolsStatus.loading)); 61 + whenListen( 62 + mockDevToolsCubit, 63 + const Stream<DevToolsState>.empty(), 64 + initialState: const DevToolsState(status: DevToolsStatus.loading), 65 + ); 66 + 67 + await tester.pumpWidget(buildSubject()); 68 + 69 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 70 + }); 71 + 72 + testWidgets('renders error state', (tester) async { 73 + when( 74 + () => mockDevToolsCubit.state, 75 + ).thenReturn(const DevToolsState(status: DevToolsStatus.error, errorMessage: 'Test error')); 76 + whenListen( 77 + mockDevToolsCubit, 78 + const Stream<DevToolsState>.empty(), 79 + initialState: const DevToolsState(status: DevToolsStatus.error, errorMessage: 'Test error'), 80 + ); 81 + 82 + await tester.pumpWidget(buildSubject()); 83 + 84 + expect(find.text('Error'), findsOneWidget); 85 + expect(find.text('Test error'), findsOneWidget); 86 + }); 87 + 88 + testWidgets('renders repo overview with collection counts', (tester) async { 89 + const state = DevToolsState( 90 + status: DevToolsStatus.repoLoaded, 91 + did: 'did:plc:test', 92 + handle: 'test.bsky.social', 93 + repoHandle: 'test.bsky.social', 94 + collections: [ 95 + CollectionSummary('app.bsky.feed.post', recordCount: 2), 96 + CollectionSummary('app.bsky.feed.like', recordCount: 3), 97 + ], 98 + ); 99 + 100 + when(() => mockDevToolsCubit.state).thenReturn(state); 101 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 102 + 103 + await tester.pumpWidget(buildSubject()); 104 + 105 + expect(find.text('test.bsky.social'), findsOneWidget); 106 + expect(find.text('did:plc:test'), findsOneWidget); 107 + expect(find.text('2 collections'), findsOneWidget); 108 + expect(find.text('5 records'), findsOneWidget); 109 + expect(find.text('app.bsky.feed.post'), findsOneWidget); 110 + expect(find.text('app.bsky.feed.like'), findsOneWidget); 111 + }); 112 + 113 + testWidgets('submitting search calls cubit resolve', (tester) async { 114 + await tester.pumpWidget(buildSubject()); 115 + 116 + await tester.enterText(find.byType(TextField), 'alice.bsky.social'); 117 + await tester.tap(find.text('Resolve')); 118 + 119 + verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1); 120 + }); 121 + 122 + testWidgets('tapping a record calls cubit loadRecord', (tester) async { 123 + const record = RepoListRecordsRecord( 124 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 125 + cid: 'cid123', 126 + value: {'text': 'Summary'}, 127 + ); 128 + const state = DevToolsState( 129 + status: DevToolsStatus.collectionLoaded, 130 + did: 'did:plc:test', 131 + handle: 'test.bsky.social', 132 + repoHandle: 'test.bsky.social', 133 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 134 + selectedCollection: 'app.bsky.feed.post', 135 + records: [record], 136 + ); 137 + 138 + when(() => mockDevToolsCubit.state).thenReturn(state); 139 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 140 + 141 + await tester.pumpWidget(buildSubject()); 142 + await tester.tap(find.text('123')); 143 + 144 + verify(() => mockDevToolsCubit.loadRecord(record)).called(1); 145 + }); 146 + }); 147 + }