mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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}