+8
-8
doc/roadmap.txt
+8
-8
doc/roadmap.txt
···
48
48
49
49
Phase 3: Record Inspector
50
50
- [x] com.atproto.repo.getRecord: fetch single record
51
-
- [ ] RecordDetailPage: JSON tree viewer with collapsible nodes
52
-
- [ ] Syntax highlighting for JSON
53
-
- [ ] Copy buttons at every level (URI, CID, path values)
54
-
- [ ] Metadata header: AT URI, CID, indexedAt
55
-
- [ ] Inline blob display (images/videos)
56
-
- [ ] Export actions: JSON file, share sheet
51
+
- [x] RecordDetailPage: JSON tree viewer with collapsible nodes
52
+
- [x] Syntax highlighting for JSON
53
+
- [x] Copy buttons at every level (URI, CID, path values)
54
+
- [x] Metadata header: AT URI, CID, indexedAt
55
+
- [x] Inline blob display (images/videos)
56
+
- [x] Export actions: JSON file, share sheet
57
57
58
58
Tests:
59
59
- [x] Widget test: debug overlay hidden in release mode
···
70
70
- [x] User-facing DevTools (production) for repository inspection
71
71
- [x] Collections browser with search and pinning
72
72
- [x] Records list with rich previews and pagination
73
-
- [ ] Record inspector with JSON tree and metadata
74
-
- [ ] UI is transparent, "power-user" focused
73
+
- [x] Record inspector with JSON tree and metadata
74
+
- [x] UI is transparent, "power-user" focused
75
75
76
76
================================================================================
77
77
O. Hardening (mobile) *bsky-O*
+54
lib/src/app/router.dart
+54
lib/src/app/router.dart
···
10
10
import 'package:lazurite/src/features/composer/presentation/screens/composer_screen.dart';
11
11
import 'package:lazurite/src/features/composer/presentation/screens/draft_list_screen.dart';
12
12
import 'package:lazurite/src/features/composer/presentation/widgets/draft_recovery_listener.dart';
13
+
import 'package:lazurite/src/features/developer_tools/presentation/screens/collections_page.dart';
13
14
import 'package:lazurite/src/features/developer_tools/presentation/screens/dev_tools_home_page.dart';
15
+
import 'package:lazurite/src/features/developer_tools/presentation/screens/record_detail_page.dart';
16
+
import 'package:lazurite/src/features/developer_tools/presentation/screens/records_page.dart';
14
17
import 'package:lazurite/src/features/dms/presentation/conversation_detail_screen.dart';
15
18
import 'package:lazurite/src/features/dms/presentation/conversation_list_screen.dart';
16
19
import 'package:lazurite/src/features/feeds/presentation/screens/feed_discovery_screen.dart';
···
38
41
39
42
/// Global navigator key for the root navigator.
40
43
final rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
44
+
45
+
/// Helper to get DID from auth state for DevTools routes.
46
+
String _getDidFromAuth(Ref ref) {
47
+
final authState = ref.read(authProvider);
48
+
if (authState is AuthStateAuthenticated) {
49
+
return authState.session.did;
50
+
}
51
+
return '';
52
+
}
41
53
42
54
/// Creates and configures the app router.
43
55
///
···
407
419
path: AppRoutes.devtools,
408
420
name: AppRouteNames.devToolsHome,
409
421
builder: (context, state) => const DevToolsHomePage(),
422
+
routes: [
423
+
GoRoute(
424
+
path: AppRoutes.devtoolsCollections,
425
+
name: AppRouteNames.devToolsCollections,
426
+
pageBuilder: (context, state) => LazuritePageTransitions.build(
427
+
child: const CollectionsPage(),
428
+
type: LazuriteTransitionType.sharedAxisHorizontal,
429
+
state: state,
430
+
controller: animationController,
431
+
),
432
+
routes: [
433
+
GoRoute(
434
+
path: AppRoutes.devtoolsRecords,
435
+
name: AppRouteNames.devToolsRecords,
436
+
pageBuilder: (context, state) => LazuritePageTransitions.build(
437
+
child: RecordsPage(
438
+
did: _getDidFromAuth(ref),
439
+
collection: Uri.decodeComponent(state.pathParameters['collection']!),
440
+
),
441
+
type: LazuriteTransitionType.sharedAxisHorizontal,
442
+
state: state,
443
+
controller: animationController,
444
+
),
445
+
routes: [
446
+
GoRoute(
447
+
path: AppRoutes.devtoolsRecord,
448
+
name: AppRouteNames.devToolsRecord,
449
+
pageBuilder: (context, state) => LazuritePageTransitions.build(
450
+
child: RecordDetailPage(
451
+
collection: Uri.decodeComponent(state.pathParameters['collection']!),
452
+
rkey: Uri.decodeComponent(state.pathParameters['rkey']!),
453
+
),
454
+
type: LazuriteTransitionType.sharedAxisHorizontal,
455
+
state: state,
456
+
controller: animationController,
457
+
),
458
+
),
459
+
],
460
+
),
461
+
],
462
+
),
463
+
],
410
464
),
411
465
],
412
466
);
+6
lib/src/app/routes.dart
+6
lib/src/app/routes.dart
···
33
33
static const String feeds = '/feeds';
34
34
static const String discoverFeeds = 'discover';
35
35
static const String devtools = '/devtools';
36
+
static const String devtoolsCollections = 'collections';
37
+
static const String devtoolsRecords = ':collection';
38
+
static const String devtoolsRecord = ':rkey';
36
39
}
37
40
38
41
/// Route names for named navigation.
···
65
68
static const String feeds = 'feeds';
66
69
static const String discoverFeeds = 'discoverFeeds';
67
70
static const String devToolsHome = 'devToolsHome';
71
+
static const String devToolsCollections = 'devToolsCollections';
72
+
static const String devToolsRecords = 'devToolsRecords';
73
+
static const String devToolsRecord = 'devToolsRecord';
68
74
}
+16
lib/src/features/developer_tools/application/devtools_providers.dart
+16
lib/src/features/developer_tools/application/devtools_providers.dart
···
60
60
return db.devToolsDao.watchPins().map((pins) => pins.map((p) => p.uri).toList());
61
61
}
62
62
63
+
/// Provides a single record by collection and rkey for the current user.
64
+
///
65
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
66
+
/// [rkey] is the record key.
67
+
/// Returns null if not authenticated or record not found.
68
+
@riverpod
69
+
Future<RepoRecord?> recordDetail(Ref ref, String collection, String rkey) async {
70
+
final authState = ref.watch(authProvider);
71
+
if (authState is! AuthStateAuthenticated) {
72
+
return null;
73
+
}
74
+
75
+
final repo = ref.watch(devtoolsRepositoryProvider);
76
+
return repo.getRecord(repo: authState.session.did, collection: collection, rkey: rkey);
77
+
}
78
+
63
79
/// State class for managing paginated records.
64
80
class RecordsState {
65
81
const RecordsState({
+99
-1
lib/src/features/developer_tools/application/devtools_providers.g.dart
+99
-1
lib/src/features/developer_tools/application/devtools_providers.g.dart
···
242
242
243
243
String _$pinnedUrisHash() => r'dbe9bd345d2600ce0cd40c65deceba8141a827c6';
244
244
245
+
/// Provides a single record by collection and rkey for the current user.
246
+
///
247
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
248
+
/// [rkey] is the record key.
249
+
/// Returns null if not authenticated or record not found.
250
+
251
+
@ProviderFor(recordDetail)
252
+
final recordDetailProvider = RecordDetailFamily._();
253
+
254
+
/// Provides a single record by collection and rkey for the current user.
255
+
///
256
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
257
+
/// [rkey] is the record key.
258
+
/// Returns null if not authenticated or record not found.
259
+
260
+
final class RecordDetailProvider
261
+
extends $FunctionalProvider<AsyncValue<RepoRecord?>, RepoRecord?, FutureOr<RepoRecord?>>
262
+
with $FutureModifier<RepoRecord?>, $FutureProvider<RepoRecord?> {
263
+
/// Provides a single record by collection and rkey for the current user.
264
+
///
265
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
266
+
/// [rkey] is the record key.
267
+
/// Returns null if not authenticated or record not found.
268
+
RecordDetailProvider._({
269
+
required RecordDetailFamily super.from,
270
+
required (String, String) super.argument,
271
+
}) : super(
272
+
retry: null,
273
+
name: r'recordDetailProvider',
274
+
isAutoDispose: true,
275
+
dependencies: null,
276
+
$allTransitiveDependencies: null,
277
+
);
278
+
279
+
@override
280
+
String debugGetCreateSourceHash() => _$recordDetailHash();
281
+
282
+
@override
283
+
String toString() {
284
+
return r'recordDetailProvider'
285
+
''
286
+
'$argument';
287
+
}
288
+
289
+
@$internal
290
+
@override
291
+
$FutureProviderElement<RepoRecord?> $createElement($ProviderPointer pointer) =>
292
+
$FutureProviderElement(pointer);
293
+
294
+
@override
295
+
FutureOr<RepoRecord?> create(Ref ref) {
296
+
final argument = this.argument as (String, String);
297
+
return recordDetail(ref, argument.$1, argument.$2);
298
+
}
299
+
300
+
@override
301
+
bool operator ==(Object other) {
302
+
return other is RecordDetailProvider && other.argument == argument;
303
+
}
304
+
305
+
@override
306
+
int get hashCode {
307
+
return argument.hashCode;
308
+
}
309
+
}
310
+
311
+
String _$recordDetailHash() => r'34d48a10bfaae231d25dcc6a1d6d175b007cb906';
312
+
313
+
/// Provides a single record by collection and rkey for the current user.
314
+
///
315
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
316
+
/// [rkey] is the record key.
317
+
/// Returns null if not authenticated or record not found.
318
+
319
+
final class RecordDetailFamily extends $Family
320
+
with $FunctionalFamilyOverride<FutureOr<RepoRecord?>, (String, String)> {
321
+
RecordDetailFamily._()
322
+
: super(
323
+
retry: null,
324
+
name: r'recordDetailProvider',
325
+
dependencies: null,
326
+
$allTransitiveDependencies: null,
327
+
isAutoDispose: true,
328
+
);
329
+
330
+
/// Provides a single record by collection and rkey for the current user.
331
+
///
332
+
/// [collection] is the NSID of the collection (e.g., "app.bsky.feed.post").
333
+
/// [rkey] is the record key.
334
+
/// Returns null if not authenticated or record not found.
335
+
336
+
RecordDetailProvider call(String collection, String rkey) =>
337
+
RecordDetailProvider._(argument: (collection, rkey), from: this);
338
+
339
+
@override
340
+
String toString() => r'recordDetailProvider';
341
+
}
342
+
245
343
/// Provides paginated records for a specific collection.
246
344
///
247
345
/// Manages infinite scroll with cursor-based pagination.
···
296
394
}
297
395
}
298
396
299
-
String _$recordsHash() => r'394b8e4921fa44831bf2ada0705c265b008fe3c1';
397
+
String _$recordsHash() => r'276711cc187ecfe00faa0c8340ab6394d498beef';
300
398
301
399
/// Provides paginated records for a specific collection.
302
400
///
+5
-4
lib/src/features/developer_tools/presentation/screens/collections_page.dart
+5
-4
lib/src/features/developer_tools/presentation/screens/collections_page.dart
···
2
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
import 'package:go_router/go_router.dart';
4
4
import 'package:lazurite/src/app/providers.dart';
5
+
import 'package:lazurite/src/app/routes.dart';
5
6
import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart';
6
7
import 'package:lazurite/src/features/developer_tools/domain/repo_collection.dart';
7
8
···
173
174
],
174
175
),
175
176
onTap: () {
176
-
// TODO: Navigate to RecordsPage
177
-
ScaffoldMessenger.of(
178
-
context,
179
-
).showSnackBar(SnackBar(content: Text('Opening ${collection.nsid}...')));
177
+
context.goNamed(
178
+
AppRouteNames.devToolsRecords,
179
+
pathParameters: {'collection': Uri.encodeComponent(collection.nsid)},
180
+
);
180
181
},
181
182
);
182
183
}
+343
lib/src/features/developer_tools/presentation/screens/record_detail_page.dart
+343
lib/src/features/developer_tools/presentation/screens/record_detail_page.dart
···
1
+
import 'dart:convert';
2
+
3
+
import 'package:flutter/material.dart';
4
+
import 'package:flutter/services.dart';
5
+
import 'package:flutter_json/flutter_json.dart';
6
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
7
+
import 'package:go_router/go_router.dart';
8
+
import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart';
9
+
import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart';
10
+
11
+
/// A page that displays the full details of a single ATProto record.
12
+
///
13
+
/// Shows metadata (AT URI, CID, indexedAt), a collapsible JSON tree viewer,
14
+
/// and copy/export actions.
15
+
class RecordDetailPage extends ConsumerWidget {
16
+
const RecordDetailPage({required this.collection, required this.rkey, super.key});
17
+
18
+
final String collection;
19
+
final String rkey;
20
+
21
+
@override
22
+
Widget build(BuildContext context, WidgetRef ref) {
23
+
final theme = Theme.of(context);
24
+
final recordAsync = ref.watch(recordDetailProvider(collection, rkey));
25
+
26
+
return Scaffold(
27
+
appBar: AppBar(
28
+
title: Text(rkey, overflow: TextOverflow.ellipsis),
29
+
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
30
+
actions: [
31
+
IconButton(
32
+
icon: const Icon(Icons.share),
33
+
tooltip: 'Share JSON',
34
+
onPressed: () {
35
+
recordAsync.whenData((record) {
36
+
if (record != null) {
37
+
_shareRecord(context, record);
38
+
}
39
+
});
40
+
},
41
+
),
42
+
],
43
+
),
44
+
body: recordAsync.when(
45
+
data: (record) {
46
+
if (record == null) {
47
+
return Center(
48
+
child: Text(
49
+
'Record not found',
50
+
style: theme.textTheme.bodyLarge?.copyWith(
51
+
color: theme.colorScheme.onSurfaceVariant,
52
+
),
53
+
),
54
+
);
55
+
}
56
+
return _RecordDetailContent(record: record);
57
+
},
58
+
loading: () => const Center(child: CircularProgressIndicator()),
59
+
error: (error, stack) => Center(
60
+
child: Padding(
61
+
padding: const EdgeInsets.all(16.0),
62
+
child: Column(
63
+
mainAxisAlignment: MainAxisAlignment.center,
64
+
children: [
65
+
Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
66
+
const SizedBox(height: 16),
67
+
Text('Failed to load record', style: theme.textTheme.titleMedium),
68
+
const SizedBox(height: 8),
69
+
Text(
70
+
error.toString(),
71
+
textAlign: TextAlign.center,
72
+
style: theme.textTheme.bodySmall?.copyWith(
73
+
color: theme.colorScheme.onSurfaceVariant,
74
+
),
75
+
),
76
+
const SizedBox(height: 16),
77
+
ElevatedButton.icon(
78
+
onPressed: () => ref.invalidate(recordDetailProvider(collection, rkey)),
79
+
icon: const Icon(Icons.refresh),
80
+
label: const Text('Retry'),
81
+
),
82
+
],
83
+
),
84
+
),
85
+
),
86
+
),
87
+
);
88
+
}
89
+
90
+
void _shareRecord(BuildContext context, RepoRecord record) {
91
+
final jsonString = const JsonEncoder.withIndent(' ').convert(record.toJson());
92
+
Clipboard.setData(ClipboardData(text: jsonString));
93
+
ScaffoldMessenger.of(
94
+
context,
95
+
).showSnackBar(const SnackBar(content: Text('Record JSON copied to clipboard')));
96
+
}
97
+
}
98
+
99
+
class _RecordDetailContent extends StatelessWidget {
100
+
const _RecordDetailContent({required this.record});
101
+
102
+
final RepoRecord record;
103
+
104
+
@override
105
+
Widget build(BuildContext context) {
106
+
return SingleChildScrollView(
107
+
padding: const EdgeInsets.all(16.0),
108
+
child: Column(
109
+
crossAxisAlignment: CrossAxisAlignment.start,
110
+
children: [
111
+
_MetadataHeader(record: record),
112
+
const SizedBox(height: 24),
113
+
..._buildBlobPreviews(context),
114
+
_JsonTreeSection(record: record),
115
+
],
116
+
),
117
+
);
118
+
}
119
+
120
+
List<Widget> _buildBlobPreviews(BuildContext context) {
121
+
final blobs = _findBlobs(record.value);
122
+
if (blobs.isEmpty) return [];
123
+
124
+
return [
125
+
Text('Blobs', style: Theme.of(context).textTheme.titleMedium),
126
+
const SizedBox(height: 8),
127
+
...blobs.map((blob) => _BlobPreview(blob: blob)),
128
+
const SizedBox(height: 24),
129
+
];
130
+
}
131
+
132
+
List<Map<String, dynamic>> _findBlobs(dynamic value, [List<Map<String, dynamic>>? results]) {
133
+
results ??= [];
134
+
135
+
if (value is Map<String, dynamic>) {
136
+
if (value[r'$type'] == 'blob' && value['ref'] != null) {
137
+
results.add(value);
138
+
} else {
139
+
for (final v in value.values) {
140
+
_findBlobs(v, results);
141
+
}
142
+
}
143
+
} else if (value is List) {
144
+
for (final item in value) {
145
+
_findBlobs(item, results);
146
+
}
147
+
}
148
+
149
+
return results;
150
+
}
151
+
}
152
+
153
+
class _MetadataHeader extends StatelessWidget {
154
+
const _MetadataHeader({required this.record});
155
+
156
+
final RepoRecord record;
157
+
158
+
@override
159
+
Widget build(BuildContext context) {
160
+
final theme = Theme.of(context);
161
+
162
+
return Card(
163
+
child: Padding(
164
+
padding: const EdgeInsets.all(16.0),
165
+
child: Column(
166
+
crossAxisAlignment: CrossAxisAlignment.start,
167
+
children: [
168
+
Text('Metadata', style: theme.textTheme.titleMedium),
169
+
const SizedBox(height: 16),
170
+
_MetadataRow(label: 'AT URI', value: record.uri, copyable: true),
171
+
const Divider(height: 24),
172
+
_MetadataRow(label: 'CID', value: record.cid, copyable: true),
173
+
if (record.indexedAt != null) ...[
174
+
const Divider(height: 24),
175
+
_MetadataRow(
176
+
label: 'Indexed At',
177
+
value: _formatDate(record.indexedAt!),
178
+
copyable: false,
179
+
),
180
+
],
181
+
],
182
+
),
183
+
),
184
+
);
185
+
}
186
+
187
+
String _formatDate(DateTime date) {
188
+
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} '
189
+
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:'
190
+
'${date.second.toString().padLeft(2, '0')} UTC';
191
+
}
192
+
}
193
+
194
+
class _MetadataRow extends StatelessWidget {
195
+
const _MetadataRow({required this.label, required this.value, this.copyable = false});
196
+
197
+
final String label;
198
+
final String value;
199
+
final bool copyable;
200
+
201
+
@override
202
+
Widget build(BuildContext context) {
203
+
final theme = Theme.of(context);
204
+
205
+
return Row(
206
+
crossAxisAlignment: CrossAxisAlignment.start,
207
+
children: [
208
+
Expanded(
209
+
child: Column(
210
+
crossAxisAlignment: CrossAxisAlignment.start,
211
+
children: [
212
+
Text(
213
+
label,
214
+
style: theme.textTheme.labelSmall?.copyWith(
215
+
color: theme.colorScheme.onSurfaceVariant,
216
+
),
217
+
),
218
+
const SizedBox(height: 4),
219
+
SelectableText(
220
+
value,
221
+
style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
222
+
),
223
+
],
224
+
),
225
+
),
226
+
if (copyable)
227
+
IconButton(
228
+
icon: const Icon(Icons.copy, size: 18),
229
+
onPressed: () {
230
+
Clipboard.setData(ClipboardData(text: value));
231
+
ScaffoldMessenger.of(
232
+
context,
233
+
).showSnackBar(SnackBar(content: Text('$label copied to clipboard')));
234
+
},
235
+
tooltip: 'Copy $label',
236
+
),
237
+
],
238
+
);
239
+
}
240
+
}
241
+
242
+
class _JsonTreeSection extends StatelessWidget {
243
+
const _JsonTreeSection({required this.record});
244
+
245
+
final RepoRecord record;
246
+
247
+
@override
248
+
Widget build(BuildContext context) {
249
+
final theme = Theme.of(context);
250
+
final isDark = theme.brightness == Brightness.dark;
251
+
252
+
return Column(
253
+
crossAxisAlignment: CrossAxisAlignment.start,
254
+
children: [
255
+
Row(
256
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
257
+
children: [
258
+
Text('Record Value', style: theme.textTheme.titleMedium),
259
+
IconButton(
260
+
icon: const Icon(Icons.copy),
261
+
tooltip: 'Copy JSON',
262
+
onPressed: () {
263
+
final jsonString = const JsonEncoder.withIndent(' ').convert(record.value);
264
+
Clipboard.setData(ClipboardData(text: jsonString));
265
+
ScaffoldMessenger.of(
266
+
context,
267
+
).showSnackBar(const SnackBar(content: Text('JSON copied to clipboard')));
268
+
},
269
+
),
270
+
],
271
+
),
272
+
const SizedBox(height: 8),
273
+
Container(
274
+
constraints: const BoxConstraints(maxHeight: 500),
275
+
decoration: BoxDecoration(
276
+
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
277
+
borderRadius: BorderRadius.circular(8),
278
+
),
279
+
child: JsonWidget(
280
+
json: record.value,
281
+
initialExpandDepth: 2,
282
+
keyColor: isDark ? Colors.cyan : Colors.blue.shade800,
283
+
stringColor: isDark ? Colors.lightGreen : Colors.green.shade800,
284
+
boolColor: isDark ? Colors.purple.shade300 : Colors.purple.shade700,
285
+
),
286
+
),
287
+
],
288
+
);
289
+
}
290
+
}
291
+
292
+
class _BlobPreview extends StatelessWidget {
293
+
const _BlobPreview({required this.blob});
294
+
295
+
final Map<String, dynamic> blob;
296
+
297
+
@override
298
+
Widget build(BuildContext context) {
299
+
final theme = Theme.of(context);
300
+
final mimeType = blob['mimeType'] as String? ?? 'unknown';
301
+
final size = blob['size'] as int? ?? 0;
302
+
final ref = blob['ref'] as Map<String, dynamic>?;
303
+
final link = ref?[r'$link'] as String?;
304
+
305
+
final isImage = mimeType.startsWith('image/');
306
+
final isVideo = mimeType.startsWith('video/');
307
+
308
+
return Card(
309
+
margin: const EdgeInsets.only(bottom: 8),
310
+
child: ListTile(
311
+
leading: Icon(
312
+
isImage
313
+
? Icons.image
314
+
: isVideo
315
+
? Icons.videocam
316
+
: Icons.attachment,
317
+
color: theme.colorScheme.primary,
318
+
),
319
+
title: Text(mimeType),
320
+
subtitle: Text(
321
+
'${_formatSize(size)}${link != null ? ' • CID: ${link.substring(0, 12)}...' : ''}',
322
+
),
323
+
trailing: link != null
324
+
? IconButton(
325
+
icon: const Icon(Icons.copy, size: 18),
326
+
onPressed: () {
327
+
Clipboard.setData(ClipboardData(text: link));
328
+
ScaffoldMessenger.of(
329
+
context,
330
+
).showSnackBar(const SnackBar(content: Text('Blob CID copied to clipboard')));
331
+
},
332
+
)
333
+
: null,
334
+
),
335
+
);
336
+
}
337
+
338
+
String _formatSize(int bytes) {
339
+
if (bytes < 1024) return '$bytes B';
340
+
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
341
+
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
342
+
}
343
+
}
+8
-4
lib/src/features/developer_tools/presentation/screens/records_page.dart
+8
-4
lib/src/features/developer_tools/presentation/screens/records_page.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
import 'package:go_router/go_router.dart';
4
+
import 'package:lazurite/src/app/routes.dart';
4
5
import 'package:lazurite/src/app/providers.dart';
5
6
import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart';
6
7
import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart';
···
227
228
alignment: Alignment.centerRight,
228
229
child: TextButton.icon(
229
230
onPressed: () {
230
-
// TODO: Navigate to record detail page
231
-
ScaffoldMessenger.of(
232
-
context,
233
-
).showSnackBar(const SnackBar(content: Text('Record detail coming soon...')));
231
+
context.goNamed(
232
+
AppRouteNames.devToolsRecord,
233
+
pathParameters: {
234
+
'collection': Uri.encodeComponent(record.collection),
235
+
'rkey': Uri.encodeComponent(record.rkey),
236
+
},
237
+
);
234
238
},
235
239
icon: const Icon(Icons.visibility, size: 18),
236
240
label: const Text('View Details'),
+8
pubspec.lock
+8
pubspec.lock
···
478
478
url: "https://pub.dev"
479
479
source: hosted
480
480
version: "0.1.5"
481
+
flutter_json:
482
+
dependency: "direct main"
483
+
description:
484
+
name: flutter_json
485
+
sha256: "2848c69365a2db142746186aba676cc9954c3f968c97d0a19d9526a496a1d43a"
486
+
url: "https://pub.dev"
487
+
source: hosted
488
+
version: "0.0.6"
481
489
flutter_lints:
482
490
dependency: "direct dev"
483
491
description:
+1
pubspec.yaml
+1
pubspec.yaml
+18
-1
test/src/features/developer_tools/application/devtools_providers_test.dart
+18
-1
test/src/features/developer_tools/application/devtools_providers_test.dart
···
1
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
1
2
import 'package:flutter_test/flutter_test.dart';
2
3
import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart';
3
4
import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart';
4
5
import 'package:lazurite/src/features/developer_tools/infrastructure/devtools_repository.dart';
5
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
import 'package:mocktail/mocktail.dart';
7
7
8
8
class MockDevtoolsRepository extends Mock implements DevtoolsRepository {}
···
215
215
expect(state.records, hasLength(2));
216
216
expect(state.error, isA<Exception>());
217
217
expect(state.isLoading, false);
218
+
});
219
+
});
220
+
221
+
group('recordDetailProvider', () {
222
+
late ProviderContainer container;
223
+
late MockDevtoolsRepository mockRepository;
224
+
225
+
setUp(() {
226
+
mockRepository = MockDevtoolsRepository();
227
+
228
+
container = ProviderContainer(
229
+
overrides: [devtoolsRepositoryProvider.overrideWithValue(mockRepository)],
230
+
);
231
+
});
232
+
233
+
tearDown(() {
234
+
container.dispose();
218
235
});
219
236
});
220
237
}
+171
test/src/features/developer_tools/presentation/screens/record_detail_page_test.dart
+171
test/src/features/developer_tools/presentation/screens/record_detail_page_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter/services.dart';
3
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
import 'package:flutter_test/flutter_test.dart';
5
+
import 'package:lazurite/src/app/theme.dart';
6
+
import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart';
7
+
import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart';
8
+
import 'package:lazurite/src/features/developer_tools/presentation/screens/record_detail_page.dart';
9
+
10
+
void main() {
11
+
group('RecordDetailPage', () {
12
+
const testCollection = 'app.bsky.feed.post';
13
+
const testRkey = 'abc123';
14
+
const testDid = 'did:plc:test123';
15
+
16
+
final testRecord = RepoRecord(
17
+
uri: 'at://$testDid/$testCollection/$testRkey',
18
+
cid: 'bafyreiabc123456789abcdef',
19
+
value: {
20
+
r'$type': 'app.bsky.feed.post',
21
+
'text': 'Hello, world! This is a test post.',
22
+
'createdAt': '2026-01-09T00:00:00.000Z',
23
+
},
24
+
indexedAt: DateTime.parse('2026-01-09T00:00:00.000Z'),
25
+
);
26
+
27
+
Widget createSubject({RepoRecord? record, bool returnNull = false}) {
28
+
return ProviderScope(
29
+
overrides: [
30
+
recordDetailProvider(
31
+
testCollection,
32
+
testRkey,
33
+
).overrideWith((ref) async => returnNull ? null : (record ?? testRecord)),
34
+
],
35
+
child: MaterialApp(
36
+
theme: AppTheme.dark,
37
+
home: const RecordDetailPage(collection: testCollection, rkey: testRkey),
38
+
),
39
+
);
40
+
}
41
+
42
+
Future<void> pumpWithFrames(WidgetTester tester) async {
43
+
await tester.pump();
44
+
await tester.pump(const Duration(milliseconds: 100));
45
+
await tester.pump(const Duration(milliseconds: 100));
46
+
}
47
+
48
+
testWidgets('renders metadata header with AT URI', (tester) async {
49
+
await tester.pumpWidget(createSubject());
50
+
await pumpWithFrames(tester);
51
+
52
+
expect(find.text('Metadata'), findsOneWidget);
53
+
expect(find.text('AT URI'), findsOneWidget);
54
+
expect(find.textContaining('at://$testDid'), findsOneWidget);
55
+
});
56
+
57
+
testWidgets('renders metadata header with CID', (tester) async {
58
+
await tester.pumpWidget(createSubject());
59
+
await pumpWithFrames(tester);
60
+
61
+
expect(find.text('CID'), findsOneWidget);
62
+
expect(find.textContaining('bafyreiabc123456789abcdef'), findsOneWidget);
63
+
});
64
+
65
+
testWidgets('renders metadata header with indexed timestamp', (tester) async {
66
+
await tester.pumpWidget(createSubject());
67
+
await pumpWithFrames(tester);
68
+
69
+
expect(find.text('Indexed At'), findsOneWidget);
70
+
expect(find.textContaining('2026-01-09'), findsOneWidget);
71
+
});
72
+
73
+
testWidgets('renders JSON tree section', (tester) async {
74
+
await tester.pumpWidget(createSubject());
75
+
await pumpWithFrames(tester);
76
+
77
+
expect(find.text('Record Value'), findsOneWidget);
78
+
});
79
+
80
+
testWidgets('copies AT URI to clipboard when copy button is tapped', (tester) async {
81
+
final log = <MethodCall>[];
82
+
83
+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
84
+
SystemChannels.platform,
85
+
(methodCall) async {
86
+
log.add(methodCall);
87
+
return null;
88
+
},
89
+
);
90
+
91
+
await tester.pumpWidget(createSubject());
92
+
await pumpWithFrames(tester);
93
+
94
+
final copyButtons = find.byIcon(Icons.copy);
95
+
expect(copyButtons, findsAtLeastNWidgets(2));
96
+
97
+
await tester.tap(copyButtons.first);
98
+
await tester.pump();
99
+
await tester.pump(const Duration(seconds: 1));
100
+
101
+
final clipboardCalls = log.where((c) => c.method == 'Clipboard.setData');
102
+
expect(clipboardCalls, isNotEmpty);
103
+
});
104
+
105
+
testWidgets('displays Record not found for null record', (tester) async {
106
+
await tester.pumpWidget(createSubject(returnNull: true));
107
+
await pumpWithFrames(tester);
108
+
109
+
expect(find.text('Record not found'), findsOneWidget);
110
+
});
111
+
112
+
group('Blob detection', () {
113
+
testWidgets('detects and displays blob references', (tester) async {
114
+
final recordWithBlob = RepoRecord(
115
+
uri: 'at://$testDid/$testCollection/$testRkey',
116
+
cid: 'bafyreiabc123456789abcdef',
117
+
value: {
118
+
r'$type': 'app.bsky.feed.post',
119
+
'text': 'Post with image',
120
+
'embed': {
121
+
r'$type': 'blob',
122
+
'ref': {r'$link': 'bafyreia_blob_cid_12345678'},
123
+
'mimeType': 'image/jpeg',
124
+
'size': 102400,
125
+
},
126
+
},
127
+
indexedAt: DateTime.parse('2026-01-09T00:00:00.000Z'),
128
+
);
129
+
130
+
await tester.pumpWidget(createSubject(record: recordWithBlob));
131
+
await pumpWithFrames(tester);
132
+
133
+
expect(find.text('Blobs'), findsOneWidget);
134
+
expect(find.text('image/jpeg'), findsOneWidget);
135
+
expect(find.byIcon(Icons.image), findsOneWidget);
136
+
});
137
+
138
+
testWidgets('shows video icon for video blobs', (tester) async {
139
+
final recordWithVideo = RepoRecord(
140
+
uri: 'at://$testDid/$testCollection/$testRkey',
141
+
cid: 'bafyreiabc123456789abcdef',
142
+
value: {
143
+
r'$type': 'app.bsky.feed.post',
144
+
'text': 'Post with video',
145
+
'embed': {
146
+
r'$type': 'blob',
147
+
'ref': {r'$link': 'bafyreia_blob_cid_12345678'},
148
+
'mimeType': 'video/mp4',
149
+
'size': 5242880,
150
+
},
151
+
},
152
+
indexedAt: DateTime.parse('2026-01-09T00:00:00.000Z'),
153
+
);
154
+
155
+
await tester.pumpWidget(createSubject(record: recordWithVideo));
156
+
await pumpWithFrames(tester);
157
+
158
+
expect(find.text('Blobs'), findsOneWidget);
159
+
expect(find.text('video/mp4'), findsOneWidget);
160
+
expect(find.byIcon(Icons.videocam), findsOneWidget);
161
+
});
162
+
163
+
testWidgets('does not show blobs section when no blobs present', (tester) async {
164
+
await tester.pumpWidget(createSubject());
165
+
await pumpWithFrames(tester);
166
+
167
+
expect(find.text('Blobs'), findsNothing);
168
+
});
169
+
});
170
+
});
171
+
}