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

feat: record details screen

Changed files
+737 -18
doc
lib
test
src
features
developer_tools
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 70 70 flutter_svg: ^2.0.10+1 71 71 dynamic_color: ^1.8.1 72 72 package_info_plus: ^9.0.0 73 + flutter_json: ^0.0.6 73 74 74 75 dev_dependencies: 75 76 flutter_test:
+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
··· 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 + }