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.

at main 746 lines 24 kB view raw
1import 'dart:async'; 2import 'dart:convert'; 3 4import 'package:atproto/com_atproto_repo_listrecords.dart'; 5import 'package:bluesky/app_bsky_actor_defs.dart'; 6import 'package:flutter/material.dart'; 7import 'package:flutter/services.dart'; 8import 'package:flutter_bloc/flutter_bloc.dart'; 9import 'package:lazurite/core/theme/typography.dart'; 10import 'package:lazurite/core/theme/theme_extensions.dart'; 11import 'package:lazurite/core/widgets/app_breadcrumbs.dart'; 12import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 13import 'package:url_launcher/url_launcher.dart'; 14 15class DevToolsScreen extends StatelessWidget { 16 const DevToolsScreen({super.key, this.initialQuery}); 17 18 final String? initialQuery; 19 20 @override 21 Widget build(BuildContext context) { 22 return Scaffold( 23 appBar: AppBar( 24 title: const Text('PDS Explorer'), 25 actions: [ 26 IconButton( 27 icon: const Icon(Icons.open_in_new), 28 tooltip: 'Go to pds.ls', 29 onPressed: () => _openExternalUrl('https://pds.ls'), 30 ), 31 ], 32 ), 33 body: BlocConsumer<DevToolsCubit, DevToolsState>( 34 listenWhen: (previous, current) => 35 previous.errorMessage != current.errorMessage && 36 current.errorMessage != null && 37 current.status != DevToolsStatus.error, 38 listener: (context, state) { 39 final message = state.errorMessage; 40 if (message == null) { 41 return; 42 } 43 44 ScaffoldMessenger.of(context) 45 ..hideCurrentSnackBar() 46 ..showSnackBar(SnackBar(content: Text(message), behavior: SnackBarBehavior.floating)); 47 }, 48 builder: (context, state) { 49 return Column( 50 children: [ 51 _SearchInput(state: state, initialQuery: initialQuery), 52 if (state.status == DevToolsStatus.repoLoaded || 53 state.status == DevToolsStatus.collectionLoaded || 54 state.status == DevToolsStatus.recordLoaded) 55 _BreadcrumbBar(state: state), 56 Expanded(child: _Content(state: state)), 57 ], 58 ); 59 }, 60 ), 61 ); 62 } 63} 64 65class _SearchInput extends StatefulWidget { 66 const _SearchInput({required this.state, this.initialQuery}); 67 68 final DevToolsState state; 69 final String? initialQuery; 70 71 @override 72 State<_SearchInput> createState() => _SearchInputState(); 73} 74 75class _SearchInputState extends State<_SearchInput> { 76 late final TextEditingController _controller; 77 bool _resolvedInitialQuery = false; 78 79 @override 80 void initState() { 81 super.initState(); 82 _controller = TextEditingController(text: widget.initialQuery ?? ''); 83 final initialQuery = widget.initialQuery?.trim(); 84 if (initialQuery != null && initialQuery.isNotEmpty) { 85 WidgetsBinding.instance.addPostFrameCallback((_) { 86 if (!mounted || _resolvedInitialQuery) { 87 return; 88 } 89 _resolvedInitialQuery = true; 90 _resolve(initialQuery); 91 }); 92 } 93 } 94 95 @override 96 void dispose() { 97 _controller.dispose(); 98 super.dispose(); 99 } 100 101 @override 102 Widget build(BuildContext context) { 103 final shouldShowTypeahead = 104 _controller.text.trim().startsWith('@') && 105 (widget.state.isTypeaheadLoading || widget.state.typeaheadActors.isNotEmpty); 106 107 return Padding( 108 padding: const EdgeInsets.all(16), 109 child: Column( 110 crossAxisAlignment: CrossAxisAlignment.start, 111 children: [ 112 Row( 113 children: [ 114 Expanded( 115 child: TextField( 116 controller: _controller, 117 decoration: const InputDecoration( 118 hintText: 'Handle, DID, or at:// URI', 119 border: OutlineInputBorder(), 120 contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), 121 isDense: true, 122 ), 123 style: AppTypography.googleSansCode(fontSize: 13), 124 onChanged: _onQueryChanged, 125 onSubmitted: _resolve, 126 ), 127 ), 128 const SizedBox(width: 8), 129 FilledButton( 130 onPressed: widget.state.isLoading ? null : () => _resolve(_controller.text), 131 child: const Text('Resolve'), 132 ), 133 ], 134 ), 135 if (shouldShowTypeahead) ...[ 136 const SizedBox(height: 8), 137 _TypeaheadResults( 138 actors: widget.state.typeaheadActors, 139 isLoading: widget.state.isTypeaheadLoading, 140 onSelected: _resolveHandleSuggestion, 141 ), 142 ], 143 ], 144 ), 145 ); 146 } 147 148 void _resolve(String value) { 149 final query = _normalizeHandleQuery(value); 150 if (query.isEmpty) { 151 return; 152 } 153 154 final cubit = context.read<DevToolsCubit>(); 155 cubit.clearTypeahead(); 156 cubit.resolve(query); 157 } 158 159 void _resolveHandleSuggestion(ProfileViewBasic actor) { 160 final suggestion = '@${actor.handle}'; 161 _controller 162 ..text = suggestion 163 ..selection = TextSelection.collapsed(offset: suggestion.length); 164 _resolve(suggestion); 165 } 166 167 void _onQueryChanged(String value) { 168 setState(() {}); 169 170 final cubit = context.read<DevToolsCubit>(); 171 final query = value.trim(); 172 if (!query.startsWith('@')) { 173 cubit.clearTypeahead(); 174 return; 175 } 176 177 unawaited(cubit.queryTypeahead(query)); 178 } 179 180 String _normalizeHandleQuery(String value) { 181 final query = value.trim(); 182 if (query.startsWith('@') && !query.startsWith('at://')) { 183 return query.replaceFirst(RegExp(r'^@+'), ''); 184 } 185 return query; 186 } 187} 188 189class _TypeaheadResults extends StatelessWidget { 190 const _TypeaheadResults({required this.actors, required this.isLoading, required this.onSelected}); 191 192 final List<ProfileViewBasic> actors; 193 final bool isLoading; 194 final ValueChanged<ProfileViewBasic> onSelected; 195 196 @override 197 Widget build(BuildContext context) { 198 final theme = Theme.of(context); 199 final listHeight = (actors.length * 56.0).clamp(56.0, 220.0); 200 201 return Container( 202 decoration: BoxDecoration( 203 border: Border.all(color: theme.dividerColor), 204 borderRadius: BorderRadius.circular(8), 205 color: theme.colorScheme.surface, 206 ), 207 child: Column( 208 mainAxisSize: MainAxisSize.min, 209 children: [ 210 if (isLoading) const LinearProgressIndicator(minHeight: 2), 211 if (actors.isNotEmpty) 212 SizedBox( 213 height: listHeight, 214 child: ListView.separated( 215 itemCount: actors.length, 216 separatorBuilder: (_, _) => Divider(height: 1, color: theme.dividerColor), 217 itemBuilder: (context, index) { 218 final actor = actors[index]; 219 return ListTile( 220 dense: true, 221 title: Text(actor.displayName ?? actor.handle), 222 subtitle: Text('@${actor.handle}'), 223 onTap: () => onSelected(actor), 224 ); 225 }, 226 ), 227 ), 228 ], 229 ), 230 ); 231 } 232} 233 234class _BreadcrumbBar extends StatelessWidget { 235 const _BreadcrumbBar({required this.state}); 236 237 final DevToolsState state; 238 239 @override 240 Widget build(BuildContext context) { 241 return AppBreadcrumbs(items: _items(context), isLoading: state.isNavigating); 242 } 243 244 List<AppBreadcrumbItem> _items(BuildContext context) { 245 final cubit = context.read<DevToolsCubit>(); 246 final repoLabel = state.repoHandle ?? state.handle ?? state.did ?? 'Repository'; 247 final items = <AppBreadcrumbItem>[ 248 AppBreadcrumbItem( 249 label: repoLabel, 250 tooltip: state.did == null ? repoLabel : '$repoLabel\n${state.did}', 251 key: const ValueKey('dev-tools-breadcrumb-repo'), 252 onTap: state.status == DevToolsStatus.repoLoaded ? null : cubit.goBackToRepo, 253 ), 254 ]; 255 256 if (state.selectedCollection != null) { 257 items.add( 258 AppBreadcrumbItem( 259 label: state.selectedCollection!, 260 key: const ValueKey('dev-tools-breadcrumb-collection'), 261 onTap: state.status == DevToolsStatus.collectionLoaded ? null : cubit.goBackToCollection, 262 ), 263 ); 264 } 265 266 if (state.selectedRecord != null) { 267 items.add( 268 AppBreadcrumbItem( 269 label: state.selectedRecord!.rkey.isEmpty ? 'Record JSON' : state.selectedRecord!.rkey, 270 tooltip: state.selectedRecord!.uri, 271 key: const ValueKey('dev-tools-breadcrumb-record'), 272 ), 273 ); 274 } 275 276 return items; 277 } 278} 279 280class _Content extends StatelessWidget { 281 const _Content({required this.state}); 282 283 final DevToolsState state; 284 285 @override 286 Widget build(BuildContext context) { 287 if (state.isLoading && state.status == DevToolsStatus.loading) { 288 return const Center(child: CircularProgressIndicator()); 289 } 290 291 if (state.status == DevToolsStatus.error) { 292 return Center( 293 child: Padding( 294 padding: const EdgeInsets.all(16), 295 child: Column( 296 mainAxisSize: MainAxisSize.min, 297 children: [ 298 Icon(Icons.error_outline, size: 48, color: context.colorScheme.error), 299 const SizedBox(height: 16), 300 Text('Error', style: context.textTheme.titleMedium), 301 const SizedBox(height: 8), 302 Text( 303 state.errorMessage ?? 'Unknown error', 304 textAlign: TextAlign.center, 305 style: context.textTheme.bodyMedium, 306 ), 307 ], 308 ), 309 ), 310 ); 311 } 312 313 if (state.status == DevToolsStatus.recordLoaded && state.selectedRecord != null) { 314 return _RecordInspector(record: state.selectedRecord!); 315 } 316 317 if (state.status == DevToolsStatus.collectionLoaded && state.selectedCollection != null) { 318 return _RecordsList(state: state); 319 } 320 321 if (state.status == DevToolsStatus.repoLoaded && state.did != null) { 322 return _RepoOverview(state: state); 323 } 324 325 return const _EmptyState(); 326 } 327} 328 329class _EmptyState extends StatelessWidget { 330 const _EmptyState(); 331 332 @override 333 Widget build(BuildContext context) { 334 return LayoutBuilder( 335 builder: (context, constraints) { 336 return SingleChildScrollView( 337 padding: const EdgeInsets.all(24), 338 child: ConstrainedBox( 339 constraints: BoxConstraints(minHeight: constraints.maxHeight), 340 child: Center( 341 child: Column( 342 mainAxisSize: MainAxisSize.min, 343 children: [ 344 Icon(Icons.explore_outlined, size: 64, color: context.colorScheme.outline), 345 const SizedBox(height: 16), 346 Text('PDS Explorer', style: context.textTheme.titleMedium), 347 const SizedBox(height: 8), 348 Text( 349 'Enter a handle, DID, or AT-URI to explore\n' 350 'a user\'s repository.', 351 textAlign: TextAlign.center, 352 style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.outline), 353 ), 354 const SizedBox(height: 16), 355 TextButton.icon( 356 onPressed: () => _openExternalUrl('https://pds.ls'), 357 icon: const Icon(Icons.open_in_new, size: 16), 358 label: const Text('Inspired by pds.ls'), 359 ), 360 ], 361 ), 362 ), 363 ), 364 ); 365 }, 366 ); 367 } 368} 369 370class _RepoOverview extends StatelessWidget { 371 const _RepoOverview({required this.state}); 372 373 final DevToolsState state; 374 375 @override 376 Widget build(BuildContext context) { 377 final totalRepoRecords = state.totalRepoRecords; 378 final theme = Theme.of(context); 379 return ListView( 380 children: [ 381 Container( 382 padding: const EdgeInsets.all(16), 383 decoration: BoxDecoration( 384 color: context.colorScheme.surfaceContainerHighest, 385 border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 386 ), 387 child: Column( 388 crossAxisAlignment: CrossAxisAlignment.start, 389 children: [ 390 Row( 391 children: [ 392 CircleAvatar(child: Text(_initialFor(state.repoHandle ?? state.handle))), 393 const SizedBox(width: 12), 394 Expanded( 395 child: Column( 396 crossAxisAlignment: CrossAxisAlignment.start, 397 children: [ 398 Text(state.repoHandle ?? state.handle ?? 'Unknown', style: context.textTheme.titleMedium), 399 const SizedBox(height: 2), 400 Text( 401 state.did ?? '', 402 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 403 overflow: TextOverflow.ellipsis, 404 ), 405 ], 406 ), 407 ), 408 ], 409 ), 410 const SizedBox(height: 12), 411 Wrap( 412 spacing: 16, 413 runSpacing: 4, 414 children: [ 415 Text( 416 '${state.collections.length} collections', 417 style: theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.onSurface), 418 ), 419 Text( 420 totalRepoRecords == null 421 ? (state.isCollectionCountsLoading ? 'Counting records...' : 'Record counts unavailable') 422 : '$totalRepoRecords records', 423 style: context.textTheme.bodySmall!.copyWith(color: context.colorScheme.onSurfaceVariant), 424 ), 425 ], 426 ), 427 ], 428 ), 429 ), 430 Padding( 431 padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), 432 child: Text( 433 'COLLECTIONS', 434 style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 435 ), 436 ), 437 ...state.collections.map((collection) => _CollectionItem(collection: collection)), 438 ], 439 ); 440 } 441} 442 443class _CollectionItem extends StatelessWidget { 444 const _CollectionItem({required this.collection}); 445 446 final CollectionSummary collection; 447 448 @override 449 Widget build(BuildContext context) { 450 return ListTile( 451 leading: Container( 452 width: 32, 453 height: 32, 454 decoration: BoxDecoration( 455 borderRadius: BorderRadius.circular(6), 456 color: context.colorScheme.surfaceContainerHighest, 457 ), 458 child: Icon(_getCollectionIcon(collection.name), size: 16, color: context.colorScheme.primary), 459 ), 460 title: Text(collection.name, style: AppTypography.googleSansCode(fontSize: 13)), 461 trailing: Row( 462 mainAxisSize: MainAxisSize.min, 463 children: [ 464 Container( 465 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 466 decoration: BoxDecoration( 467 color: context.colorScheme.surfaceContainerHighest, 468 borderRadius: BorderRadius.circular(999), 469 ), 470 child: Text( 471 collection.countLabel, 472 style: context.textTheme.labelSmall!.copyWith(color: context.colorScheme.onSurfaceVariant), 473 ), 474 ), 475 const SizedBox(width: 4), 476 const Icon(Icons.chevron_right), 477 ], 478 ), 479 onTap: () => context.read<DevToolsCubit>().loadCollection(collection.name), 480 ); 481 } 482 483 /// NOTE: repost must come before post 484 IconData _getCollectionIcon(String collection) { 485 if (collection.contains('repost')) return Icons.repeat; 486 if (collection.contains('post')) return Icons.chat_bubble_outline; 487 if (collection.contains('like')) return Icons.favorite_outline; 488 if (collection.contains('follow')) return Icons.person_add_outlined; 489 if (collection.contains('profile')) return Icons.person_outline; 490 if (collection.contains('generator')) return Icons.auto_awesome_outlined; 491 if (collection.contains('list')) return Icons.list_outlined; 492 if (collection.contains('block')) return Icons.block; 493 return Icons.folder_outlined; 494 } 495} 496 497class _RecordsList extends StatefulWidget { 498 const _RecordsList({required this.state}); 499 500 final DevToolsState state; 501 502 @override 503 State<_RecordsList> createState() => _RecordsListState(); 504} 505 506class _RecordsListState extends State<_RecordsList> { 507 final _scrollController = ScrollController(); 508 509 @override 510 void initState() { 511 super.initState(); 512 _scrollController.addListener(_onScroll); 513 } 514 515 @override 516 void dispose() { 517 _scrollController.dispose(); 518 super.dispose(); 519 } 520 521 void _onScroll() { 522 if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 523 if (widget.state.hasMoreRecords && !widget.state.isLoading) { 524 context.read<DevToolsCubit>().loadMoreRecords(); 525 } 526 } 527 } 528 529 @override 530 Widget build(BuildContext context) { 531 final records = widget.state.records ?? []; 532 final selectedCollection = _collectionSummary(widget.state, widget.state.selectedCollection); 533 534 return Column( 535 crossAxisAlignment: CrossAxisAlignment.start, 536 children: [ 537 Container( 538 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 539 decoration: BoxDecoration( 540 color: context.colorScheme.surfaceContainerHighest, 541 border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 542 ), 543 child: Row( 544 children: [ 545 Expanded( 546 child: Text( 547 widget.state.selectedCollection ?? '', 548 style: AppTypography.googleSansCode( 549 fontSize: context.textTheme.titleSmall?.fontSize ?? 14, 550 fontWeight: context.textTheme.titleSmall?.fontWeight ?? FontWeight.w500, 551 letterSpacing: context.textTheme.titleSmall?.letterSpacing, 552 height: context.textTheme.titleSmall?.height, 553 color: context.colorScheme.primary, 554 ), 555 ), 556 ), 557 Text( 558 selectedCollection?.recordCount == null 559 ? '${records.length} loaded' 560 : '${records.length} of ${selectedCollection!.recordCount}', 561 style: context.textTheme.bodySmall, 562 ), 563 ], 564 ), 565 ), 566 Expanded( 567 child: ListView.builder( 568 controller: _scrollController, 569 itemCount: records.length + (widget.state.isLoading ? 1 : 0), 570 itemBuilder: (context, index) { 571 if (index == records.length) { 572 return const Padding( 573 padding: EdgeInsets.all(16), 574 child: Center( 575 child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), 576 ), 577 ); 578 } 579 580 final record = records[index]; 581 return _RecordItem(record: record); 582 }, 583 ), 584 ), 585 ], 586 ); 587 } 588} 589 590class _RecordItem extends StatelessWidget { 591 const _RecordItem({required this.record}); 592 593 final RepoListRecordsRecord record; 594 595 @override 596 Widget build(BuildContext context) { 597 final rkey = record.uri.rkey; 598 final preview = _getRecordPreview(record.value); 599 600 return ListTile( 601 title: Text(rkey, style: AppTypography.googleSansCode(fontSize: 13)), 602 subtitle: preview != null ? Text(preview, maxLines: 1, overflow: TextOverflow.ellipsis) : null, 603 trailing: const Icon(Icons.chevron_right), 604 onTap: () => context.read<DevToolsCubit>().loadRecord(record), 605 ); 606 } 607 608 String? _getRecordPreview(Map<String, dynamic> value) { 609 final text = value['text']; 610 if (text is String && text.isNotEmpty) { 611 return text.length > 50 ? '${text.substring(0, 50)}...' : text; 612 } 613 614 final displayName = value['displayName']; 615 if (displayName is String && displayName.isNotEmpty) { 616 return displayName; 617 } 618 619 return null; 620 } 621} 622 623class _RecordInspector extends StatelessWidget { 624 const _RecordInspector({required this.record}); 625 626 final RecordInfo record; 627 628 @override 629 Widget build(BuildContext context) { 630 final jsonString = _formatJson(record.value); 631 632 return Column( 633 children: [ 634 Container( 635 width: double.infinity, 636 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 637 decoration: BoxDecoration( 638 color: context.colorScheme.surfaceContainerHighest, 639 border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 640 ), 641 child: Column( 642 crossAxisAlignment: CrossAxisAlignment.start, 643 children: [ 644 Text( 645 record.rkey, 646 style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.primary), 647 overflow: TextOverflow.ellipsis, 648 ), 649 const SizedBox(height: 4), 650 Text( 651 record.uri, 652 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.tertiary), 653 overflow: TextOverflow.ellipsis, 654 ), 655 if (record.cid != null) ...[ 656 const SizedBox(height: 2), 657 Text( 658 'CID: ${record.cid!}', 659 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.secondary), 660 ), 661 ], 662 const SizedBox(height: 8), 663 Wrap( 664 spacing: 8, 665 runSpacing: 8, 666 children: [ 667 TextButton.icon( 668 icon: const Icon(Icons.copy, size: 16), 669 label: const Text('Copy JSON'), 670 onPressed: () { 671 Clipboard.setData(ClipboardData(text: jsonString)); 672 ScaffoldMessenger.of(context).showSnackBar( 673 const SnackBar(content: Text('JSON copied to clipboard'), behavior: SnackBarBehavior.floating), 674 ); 675 }, 676 ), 677 TextButton.icon( 678 icon: const Icon(Icons.open_in_new, size: 16), 679 label: const Text('aturi.to'), 680 onPressed: () => _openExternalUrl(record.atUriToLink), 681 ), 682 ], 683 ), 684 ], 685 ), 686 ), 687 Expanded( 688 child: SingleChildScrollView( 689 padding: const EdgeInsets.all(16), 690 child: _JsonViewer(json: record.value), 691 ), 692 ), 693 ], 694 ); 695 } 696} 697 698class _JsonViewer extends StatelessWidget { 699 const _JsonViewer({required this.json}); 700 701 final Object? json; 702 703 @override 704 Widget build(BuildContext context) { 705 return SelectableText(_formatJson(json), style: AppTypography.googleSansCode(fontSize: 12, height: 1.8)); 706 } 707} 708 709const JsonEncoder _jsonFormatter = JsonEncoder.withIndent(' '); 710 711String _formatJson(Object? value) { 712 try { 713 return _jsonFormatter.convert(value); 714 } catch (_) { 715 return value?.toString() ?? 'null'; 716 } 717} 718 719CollectionSummary? _collectionSummary(DevToolsState state, String? collectionName) { 720 if (collectionName == null) { 721 return null; 722 } 723 724 for (final collection in state.collections) { 725 if (collection.name == collectionName) { 726 return collection; 727 } 728 } 729 730 return null; 731} 732 733String _initialFor(String? value) { 734 if (value == null || value.isEmpty) { 735 return '?'; 736 } 737 738 return value.substring(0, 1).toUpperCase(); 739} 740 741Future<void> _openExternalUrl(String value) async { 742 final uri = Uri.parse(value); 743 if (await canLaunchUrl(uri)) { 744 await launchUrl(uri, mode: LaunchMode.externalApplication); 745 } 746}