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

Configure Feed

Select the types of activity you want to include in your feed.

feat: change dev tools navigation to breadcrumbs

+406 -100
+2 -2
lib/core/router/app_shell.dart
··· 431 431 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 432 432 leading: Icon(isSelected ? selectedIcon : icon, color: color), 433 433 title: Text( 434 - label, 435 - style: theme.textTheme.titleMedium?.copyWith(color: color, fontWeight: FontWeight.w600), 434 + label.toUpperCase(), 435 + style: theme.textTheme.bodyMedium?.copyWith(color: color, fontWeight: FontWeight.w700), 436 436 ), 437 437 trailing: trailing, 438 438 selected: isSelected,
+114
lib/core/widgets/app_breadcrumbs.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class AppBreadcrumbItem { 4 + const AppBreadcrumbItem({required this.label, this.onTap, this.tooltip, this.key}); 5 + 6 + final String label; 7 + final VoidCallback? onTap; 8 + final String? tooltip; 9 + final Key? key; 10 + 11 + bool get isCurrent => onTap == null; 12 + } 13 + 14 + class AppBreadcrumbs extends StatelessWidget { 15 + const AppBreadcrumbs({ 16 + super.key, 17 + required this.items, 18 + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 19 + this.isLoading = false, 20 + }); 21 + 22 + final List<AppBreadcrumbItem> items; 23 + final EdgeInsetsGeometry padding; 24 + final bool isLoading; 25 + 26 + @override 27 + Widget build(BuildContext context) { 28 + if (items.isEmpty) { 29 + return const SizedBox.shrink(); 30 + } 31 + 32 + final theme = Theme.of(context); 33 + return Container( 34 + width: double.infinity, 35 + decoration: BoxDecoration( 36 + color: theme.colorScheme.surface, 37 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 38 + ), 39 + child: Column( 40 + mainAxisSize: MainAxisSize.min, 41 + children: [ 42 + LayoutBuilder( 43 + builder: (context, constraints) { 44 + return SingleChildScrollView( 45 + scrollDirection: Axis.horizontal, 46 + padding: padding, 47 + child: ConstrainedBox( 48 + constraints: BoxConstraints(minWidth: constraints.maxWidth), 49 + child: Row( 50 + children: [ 51 + for (var index = 0; index < items.length; index++) ...[ 52 + _BreadcrumbChip(item: items[index]), 53 + if (index != items.length - 1) 54 + Padding( 55 + padding: const EdgeInsets.symmetric(horizontal: 6), 56 + child: Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant), 57 + ), 58 + ], 59 + ], 60 + ), 61 + ), 62 + ); 63 + }, 64 + ), 65 + AnimatedSwitcher( 66 + duration: const Duration(milliseconds: 180), 67 + child: isLoading 68 + ? const LinearProgressIndicator(key: ValueKey('app-breadcrumbs-loading'), minHeight: 2) 69 + : const SizedBox(key: ValueKey('app-breadcrumbs-idle'), height: 2), 70 + ), 71 + ], 72 + ), 73 + ); 74 + } 75 + } 76 + 77 + class _BreadcrumbChip extends StatelessWidget { 78 + const _BreadcrumbChip({required this.item}); 79 + 80 + final AppBreadcrumbItem item; 81 + 82 + @override 83 + Widget build(BuildContext context) { 84 + final theme = Theme.of(context); 85 + final isCurrent = item.isCurrent; 86 + final backgroundColor = isCurrent ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest; 87 + final foregroundColor = isCurrent ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurfaceVariant; 88 + 89 + final chipChild = ConstrainedBox( 90 + constraints: const BoxConstraints(maxWidth: 220), 91 + child: Padding( 92 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 93 + child: Text( 94 + item.label, 95 + key: item.key, 96 + overflow: TextOverflow.ellipsis, 97 + style: theme.textTheme.labelLarge?.copyWith(color: foregroundColor, fontWeight: FontWeight.w600), 98 + ), 99 + ), 100 + ); 101 + 102 + final chip = Material( 103 + color: backgroundColor, 104 + borderRadius: BorderRadius.circular(999), 105 + child: InkWell(onTap: item.onTap, borderRadius: BorderRadius.circular(999), child: chipChild), 106 + ); 107 + 108 + if ((item.tooltip ?? item.label).isEmpty) { 109 + return chip; 110 + } 111 + 112 + return Tooltip(message: item.tooltip ?? item.label, child: chip); 113 + } 114 + }
+29 -6
lib/features/devtools/cubit/dev_tools_cubit.dart
··· 128 128 if (state.did == null) return; 129 129 130 130 final collectionRequestId = _beginCollectionRequest(); 131 - emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); 131 + emit(state.copyWith(isCollectionLoading: true, isRecordLoading: false, errorMessage: null)); 132 132 133 133 try { 134 134 final response = await _repository.listRecords(repo: state.did!, collection: collection, limit: _pageSize); ··· 143 143 records: response.records, 144 144 recordsCursor: response.cursor, 145 145 selectedRecord: null, 146 + isCollectionLoading: false, 147 + isRecordLoading: false, 146 148 errorMessage: null, 147 149 ), 148 150 ); 149 151 } catch (error, stackTrace) { 150 152 log.e('DevToolsCubit: Failed to load collection', error: error, stackTrace: stackTrace); 151 153 if (_isActiveCollectionRequest(collectionRequestId)) { 152 - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 154 + emit(state.copyWith(isCollectionLoading: false, errorMessage: _formatError(error))); 153 155 } 154 156 } 155 157 } ··· 182 184 } catch (error, stackTrace) { 183 185 log.e('DevToolsCubit: Failed to load more records', error: error, stackTrace: stackTrace); 184 186 if (_isActiveCollectionRequest(activeCollectionRequestId)) { 185 - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 187 + emit( 188 + state.copyWith( 189 + status: DevToolsStatus.collectionLoaded, 190 + isCollectionLoading: false, 191 + isRecordLoading: false, 192 + errorMessage: _formatError(error), 193 + ), 194 + ); 186 195 } 187 196 } 188 197 } ··· 191 200 if (state.did == null) return; 192 201 193 202 final recordRequestId = _beginRecordRequest(); 194 - emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); 203 + emit(state.copyWith(isRecordLoading: true, errorMessage: null)); 195 204 196 205 try { 197 206 final resolvedRecord = await _repository.getRecord( ··· 211 220 cid: resolvedRecord.cid, 212 221 value: resolvedRecord.value, 213 222 ), 223 + isRecordLoading: false, 214 224 errorMessage: null, 215 225 ), 216 226 ); 217 227 } catch (error, stackTrace) { 218 228 log.e('DevToolsCubit: Failed to load record', error: error, stackTrace: stackTrace); 219 229 if (_isActiveRecordRequest(recordRequestId)) { 220 - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 230 + emit(state.copyWith(isRecordLoading: false, errorMessage: _formatError(error))); 221 231 } 222 232 } 223 233 } ··· 225 235 void goBackToCollection() { 226 236 _recordRequestId++; 227 237 if (state.selectedCollection != null) { 228 - emit(state.copyWith(status: DevToolsStatus.collectionLoaded, selectedRecord: null)); 238 + emit( 239 + state.copyWith( 240 + status: DevToolsStatus.collectionLoaded, 241 + selectedRecord: null, 242 + isCollectionLoading: false, 243 + isRecordLoading: false, 244 + ), 245 + ); 229 246 } else { 230 247 emit( 231 248 state.copyWith( ··· 234 251 records: null, 235 252 recordsCursor: null, 236 253 selectedRecord: null, 254 + isCollectionLoading: false, 255 + isRecordLoading: false, 237 256 ), 238 257 ); 239 258 } ··· 249 268 records: null, 250 269 recordsCursor: null, 251 270 selectedRecord: null, 271 + isCollectionLoading: false, 272 + isRecordLoading: false, 252 273 ), 253 274 ); 254 275 } ··· 400 421 records: records, 401 422 recordsCursor: recordsCursor, 402 423 selectedRecord: selectedRecord, 424 + isCollectionLoading: false, 425 + isRecordLoading: false, 403 426 ); 404 427 } 405 428
+11
lib/features/devtools/cubit/dev_tools_state.dart
··· 52 52 this.records, 53 53 this.recordsCursor, 54 54 this.selectedRecord, 55 + this.isCollectionLoading = false, 56 + this.isRecordLoading = false, 55 57 this.errorMessage, 56 58 }); 57 59 ··· 65 67 final List<RepoListRecordsRecord>? records; 66 68 final String? recordsCursor; 67 69 final RecordInfo? selectedRecord; 70 + final bool isCollectionLoading; 71 + final bool isRecordLoading; 68 72 final String? errorMessage; 69 73 70 74 bool get isLoading => status == DevToolsStatus.loading || status == DevToolsStatus.loadingMore; 75 + bool get isNavigating => isCollectionLoading || isRecordLoading; 71 76 bool get hasMoreRecords => recordsCursor != null && recordsCursor!.isNotEmpty; 72 77 int get totalRecords => records?.length ?? 0; 73 78 int? get totalRepoRecords { ··· 92 97 Object? records = _devToolsStateNoChange, 93 98 Object? recordsCursor = _devToolsStateNoChange, 94 99 Object? selectedRecord = _devToolsStateNoChange, 100 + bool? isCollectionLoading, 101 + bool? isRecordLoading, 95 102 Object? errorMessage = _devToolsStateNoChange, 96 103 }) { 97 104 return DevToolsState( ··· 109 116 selectedRecord: identical(selectedRecord, _devToolsStateNoChange) 110 117 ? this.selectedRecord 111 118 : selectedRecord as RecordInfo?, 119 + isCollectionLoading: isCollectionLoading ?? this.isCollectionLoading, 120 + isRecordLoading: isRecordLoading ?? this.isRecordLoading, 112 121 errorMessage: identical(errorMessage, _devToolsStateNoChange) ? this.errorMessage : errorMessage as String?, 113 122 ); 114 123 } ··· 125 134 records, 126 135 recordsCursor, 127 136 selectedRecord, 137 + isCollectionLoading, 138 + isRecordLoading, 128 139 errorMessage, 129 140 ]; 130 141 }
+50 -57
lib/features/devtools/presentation/dev_tools_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter/services.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:lazurite/core/widgets/app_breadcrumbs.dart'; 7 8 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 8 9 import 'package:url_launcher/url_launcher.dart'; 9 10 ··· 23 24 ), 24 25 ], 25 26 ), 26 - body: BlocBuilder<DevToolsCubit, DevToolsState>( 27 + body: BlocConsumer<DevToolsCubit, DevToolsState>( 28 + listenWhen: (previous, current) => 29 + previous.errorMessage != current.errorMessage && 30 + current.errorMessage != null && 31 + current.status != DevToolsStatus.error, 32 + listener: (context, state) { 33 + final message = state.errorMessage; 34 + if (message == null) { 35 + return; 36 + } 37 + 38 + ScaffoldMessenger.of(context) 39 + ..hideCurrentSnackBar() 40 + ..showSnackBar(SnackBar(content: Text(message), behavior: SnackBarBehavior.floating)); 41 + }, 27 42 builder: (context, state) { 28 43 return Column( 29 44 children: [ ··· 31 46 if (state.status == DevToolsStatus.repoLoaded || 32 47 state.status == DevToolsStatus.collectionLoaded || 33 48 state.status == DevToolsStatus.recordLoaded) 34 - _TabBar(state: state), 49 + _BreadcrumbBar(state: state), 35 50 Expanded(child: _Content(state: state)), 36 51 ], 37 52 ); ··· 104 119 } 105 120 } 106 121 107 - class _TabBar extends StatelessWidget { 108 - const _TabBar({required this.state}); 122 + class _BreadcrumbBar extends StatelessWidget { 123 + const _BreadcrumbBar({required this.state}); 109 124 110 125 final DevToolsState state; 111 126 112 127 @override 113 128 Widget build(BuildContext context) { 114 - return Container( 115 - decoration: BoxDecoration( 116 - border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 117 - ), 118 - child: Row( 119 - children: [ 120 - _Tab( 121 - label: 'Repo', 122 - isSelected: state.status == DevToolsStatus.repoLoaded, 123 - onTap: () => context.read<DevToolsCubit>().goBackToRepo(), 124 - ), 125 - if (state.selectedCollection != null) 126 - _Tab( 127 - label: 'Records', 128 - isSelected: state.status == DevToolsStatus.collectionLoaded, 129 - onTap: () => context.read<DevToolsCubit>().goBackToCollection(), 130 - ), 131 - if (state.selectedRecord != null) 132 - _Tab(label: 'JSON', isSelected: state.status == DevToolsStatus.recordLoaded, onTap: () {}), 133 - ], 134 - ), 135 - ); 129 + return AppBreadcrumbs(items: _items(context), isLoading: state.isNavigating); 136 130 } 137 - } 138 131 139 - class _Tab extends StatelessWidget { 140 - const _Tab({required this.label, required this.isSelected, required this.onTap}); 132 + List<AppBreadcrumbItem> _items(BuildContext context) { 133 + final cubit = context.read<DevToolsCubit>(); 134 + final repoLabel = state.repoHandle ?? state.handle ?? state.did ?? 'Repository'; 135 + final items = <AppBreadcrumbItem>[ 136 + AppBreadcrumbItem( 137 + label: repoLabel, 138 + tooltip: state.did == null ? repoLabel : '$repoLabel\n${state.did}', 139 + key: const ValueKey('dev-tools-breadcrumb-repo'), 140 + onTap: state.status == DevToolsStatus.repoLoaded ? null : cubit.goBackToRepo, 141 + ), 142 + ]; 141 143 142 - final String label; 143 - final bool isSelected; 144 - final VoidCallback onTap; 144 + if (state.selectedCollection != null) { 145 + items.add( 146 + AppBreadcrumbItem( 147 + label: state.selectedCollection!, 148 + key: const ValueKey('dev-tools-breadcrumb-collection'), 149 + onTap: state.status == DevToolsStatus.collectionLoaded ? null : cubit.goBackToCollection, 150 + ), 151 + ); 152 + } 145 153 146 - @override 147 - Widget build(BuildContext context) { 148 - return Expanded( 149 - child: InkWell( 150 - onTap: onTap, 151 - child: Container( 152 - padding: const EdgeInsets.symmetric(vertical: 12), 153 - decoration: BoxDecoration( 154 - border: Border( 155 - bottom: BorderSide( 156 - color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent, 157 - width: 2, 158 - ), 159 - ), 160 - ), 161 - child: Text( 162 - label, 163 - textAlign: TextAlign.center, 164 - style: TextStyle( 165 - fontWeight: FontWeight.w600, 166 - color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).textTheme.bodyMedium?.color, 167 - ), 168 - ), 154 + if (state.selectedRecord != null) { 155 + items.add( 156 + AppBreadcrumbItem( 157 + label: state.selectedRecord!.rkey.isEmpty ? 'Record JSON' : state.selectedRecord!.rkey, 158 + tooltip: state.selectedRecord!.uri, 159 + key: const ValueKey('dev-tools-breadcrumb-record'), 169 160 ), 170 - ), 171 - ); 161 + ); 162 + } 163 + 164 + return items; 172 165 } 173 166 } 174 167
+24 -24
lib/features/settings/presentation/settings_screen.dart
··· 153 153 children: [ 154 154 Padding( 155 155 padding: const EdgeInsets.all(16), 156 - child: SizedBox( 157 - width: double.infinity, 158 - child: SingleChildScrollView( 159 - scrollDirection: Axis.horizontal, 160 - child: SegmentedButton<_AppearanceMode>( 161 - segments: const [ 162 - ButtonSegment(value: _AppearanceMode.system, label: Text('System')), 163 - ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), 164 - ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), 165 - ], 166 - selected: {_AppearanceMode.fromState(state)}, 167 - onSelectionChanged: (selected) { 168 - final mode = selected.first; 169 - switch (mode) { 170 - case _AppearanceMode.system: 171 - settingsCubit.setUseSystemTheme(true); 172 - case _AppearanceMode.light: 173 - settingsCubit.setUseSystemTheme(false); 174 - settingsCubit.setThemeVariant(AppThemeVariant.light); 175 - case _AppearanceMode.dark: 176 - settingsCubit.setUseSystemTheme(false); 177 - settingsCubit.setThemeVariant(AppThemeVariant.dark); 178 - } 179 - }, 156 + child: Center( 157 + child: SegmentedButton<_AppearanceMode>( 158 + style: SegmentedButton.styleFrom( 159 + selectedBackgroundColor: Theme.of(context).colorScheme.primary, 160 + selectedForegroundColor: Theme.of(context).colorScheme.onPrimary, 180 161 ), 162 + segments: const [ 163 + ButtonSegment(value: _AppearanceMode.system, label: Text('System')), 164 + ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), 165 + ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), 166 + ], 167 + selected: {_AppearanceMode.fromState(state)}, 168 + onSelectionChanged: (selected) { 169 + final mode = selected.first; 170 + switch (mode) { 171 + case _AppearanceMode.system: 172 + settingsCubit.setUseSystemTheme(true); 173 + case _AppearanceMode.light: 174 + settingsCubit.setUseSystemTheme(false); 175 + settingsCubit.setThemeVariant(AppThemeVariant.light); 176 + case _AppearanceMode.dark: 177 + settingsCubit.setUseSystemTheme(false); 178 + settingsCubit.setThemeVariant(AppThemeVariant.dark); 179 + } 180 + }, 181 181 ), 182 182 ), 183 183 ),
+8 -8
test/core/router/app_router_test.dart
··· 162 162 await tester.pumpAndSettle(); 163 163 164 164 expect(find.text('Lazurite'), findsOneWidget); 165 - expect(find.text('New Post'), findsOneWidget); 166 - await tester.scrollUntilVisible(find.text('Log Out'), 200, scrollable: find.byType(Scrollable).last); 167 - expect(find.text('Log Out'), findsOneWidget); 165 + expect(find.text('NEW POST'), findsOneWidget); 166 + await tester.scrollUntilVisible(find.text('LOG OUT'), 200, scrollable: find.byType(Scrollable).last); 167 + expect(find.text('LOG OUT'), findsOneWidget); 168 168 169 - await tester.tap(find.text('Profile').last); 169 + await tester.tap(find.text('PROFILE').last); 170 170 await tester.pumpAndSettle(); 171 171 172 172 expect(find.text('RIVER TAM'), findsOneWidget); ··· 174 174 await tester.tap(find.byTooltip('Open menu')); 175 175 await tester.pumpAndSettle(); 176 176 177 - await tester.tap(find.text('Settings').last); 177 + await tester.tap(find.text('SETTINGS').last); 178 178 await tester.pumpAndSettle(); 179 179 180 180 expect(find.text('APPEARANCE'), findsOneWidget); ··· 217 217 await tester.tap(find.byTooltip('Open menu')); 218 218 await tester.pumpAndSettle(); 219 219 220 - expect(find.text('Notifications'), findsOneWidget); 221 - expect(find.text('Messages'), findsOneWidget); 222 - expect(find.text('Settings'), findsOneWidget); 220 + expect(find.text('NOTIFICATIONS'), findsOneWidget); 221 + expect(find.text('MESSAGES'), findsOneWidget); 222 + expect(find.text('SETTINGS'), findsOneWidget); 223 223 }); 224 224 225 225 testWidgets('tapping bottom nav tabs switches active branch', (tester) async {
+43 -1
test/features/devtools/cubit/dev_tools_cubit_test.dart
··· 230 230 ), 231 231 ), 232 232 expect: () => [ 233 - isA<DevToolsState>().having((state) => state.status, 'status', DevToolsStatus.loading), 233 + isA<DevToolsState>() 234 + .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 235 + .having((state) => state.isRecordLoading, 'isRecordLoading', isTrue), 234 236 isA<DevToolsState>() 235 237 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) 238 + .having((state) => state.isRecordLoading, 'isRecordLoading', isFalse) 236 239 .having((state) => state.selectedRecord?.cid, 'cid', 'cid123') 237 240 .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}), 241 + ], 242 + ); 243 + 244 + blocTest<DevToolsCubit, DevToolsState>( 245 + 'loadCollection keeps repo view active while records load', 246 + build: () { 247 + final repository = FakeDevToolsRepository( 248 + listRecordsHandler: 249 + ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { 250 + return const RepoListRecordsOutput( 251 + records: [ 252 + RepoListRecordsRecord( 253 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 254 + cid: 'cid123', 255 + value: {'text': 'Summary'}, 256 + ), 257 + ], 258 + ); 259 + }, 260 + ); 261 + 262 + return DevToolsCubit(repository: repository); 263 + }, 264 + seed: () => const DevToolsState( 265 + status: DevToolsStatus.repoLoaded, 266 + did: 'did:plc:test', 267 + repoHandle: 'test.bsky.social', 268 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 269 + ), 270 + act: (cubit) => cubit.loadCollection('app.bsky.feed.post'), 271 + expect: () => [ 272 + isA<DevToolsState>() 273 + .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 274 + .having((state) => state.isCollectionLoading, 'isCollectionLoading', isTrue), 275 + isA<DevToolsState>() 276 + .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 277 + .having((state) => state.isCollectionLoading, 'isCollectionLoading', isFalse) 278 + .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post') 279 + .having((state) => state.records?.length, 'records', 1), 238 280 ], 239 281 ); 240 282
+17 -1
test/features/devtools/cubit/dev_tools_state_test.dart
··· 58 58 expect(state.records, isNull); 59 59 expect(state.recordsCursor, isNull); 60 60 expect(state.selectedRecord, isNull); 61 + expect(state.isCollectionLoading, isFalse); 62 + expect(state.isRecordLoading, isFalse); 61 63 expect(state.errorMessage, isNull); 62 64 }); 63 65 ··· 66 68 expect(const DevToolsState(status: DevToolsStatus.loadingMore).isLoading, isTrue); 67 69 expect(const DevToolsState(status: DevToolsStatus.initial).isLoading, isFalse); 68 70 expect(const DevToolsState(status: DevToolsStatus.repoLoaded).isLoading, isFalse); 71 + }); 72 + 73 + test('isNavigating returns true for collection or record transitions', () { 74 + expect(const DevToolsState(isCollectionLoading: true).isNavigating, isTrue); 75 + expect(const DevToolsState(isRecordLoading: true).isNavigating, isTrue); 76 + expect(const DevToolsState().isNavigating, isFalse); 69 77 }); 70 78 71 79 test('hasMoreRecords returns true when cursor exists', () { ··· 145 153 records: records, 146 154 recordsCursor: 'cursor', 147 155 selectedRecord: const RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/1', value: {'text': 'full'}), 156 + isCollectionLoading: true, 157 + isRecordLoading: true, 148 158 errorMessage: 'error', 149 159 ); 150 160 ··· 157 167 records: null, 158 168 recordsCursor: null, 159 169 selectedRecord: null, 170 + isCollectionLoading: false, 171 + isRecordLoading: false, 160 172 errorMessage: null, 161 173 ); 162 174 ··· 168 180 expect(updated.records, isNull); 169 181 expect(updated.recordsCursor, isNull); 170 182 expect(updated.selectedRecord, isNull); 183 + expect(updated.isCollectionLoading, isFalse); 184 + expect(updated.isRecordLoading, isFalse); 171 185 expect(updated.errorMessage, isNull); 172 186 }); 173 187 ··· 182 196 selectedCollection: 'app.bsky.feed.post', 183 197 recordsCursor: 'cursor', 184 198 selectedRecord: RecordInfo(uri: 'at://test', value: {}), 199 + isCollectionLoading: true, 200 + isRecordLoading: true, 185 201 errorMessage: 'error', 186 202 ); 187 203 188 - expect(state.props.length, 11); 204 + expect(state.props.length, 13); 189 205 expect(state.props, contains(DevToolsStatus.repoLoaded)); 190 206 expect(state.props, contains(true)); 191 207 expect(state.props, contains('did:plc:test'));
+108 -1
test/features/devtools/presentation/dev_tools_screen_test.dart
··· 102 102 103 103 await tester.pumpWidget(buildSubject()); 104 104 105 - expect(find.text('test.bsky.social'), findsOneWidget); 105 + expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1)); 106 106 expect(find.text('did:plc:test'), findsOneWidget); 107 107 expect(find.text('2 collections'), findsOneWidget); 108 108 expect(find.text('5 records'), findsOneWidget); ··· 142 142 await tester.tap(find.text('123')); 143 143 144 144 verify(() => mockDevToolsCubit.loadRecord(record)).called(1); 145 + }); 146 + 147 + testWidgets('renders breadcrumbs for record navigation', (tester) async { 148 + const state = DevToolsState( 149 + status: DevToolsStatus.recordLoaded, 150 + did: 'did:plc:test', 151 + handle: 'test.bsky.social', 152 + repoHandle: 'test.bsky.social', 153 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 154 + selectedCollection: 'app.bsky.feed.post', 155 + selectedRecord: RecordInfo( 156 + uri: 'at://did:plc:test/app.bsky.feed.post/123', 157 + cid: 'cid123', 158 + value: {'text': 'Summary'}, 159 + ), 160 + ); 161 + 162 + when(() => mockDevToolsCubit.state).thenReturn(state); 163 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 164 + 165 + await tester.pumpWidget(buildSubject()); 166 + 167 + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-repo')), findsOneWidget); 168 + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-collection')), findsOneWidget); 169 + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-record')), findsOneWidget); 170 + expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1)); 171 + expect(find.text('app.bsky.feed.post'), findsAtLeastNWidgets(1)); 172 + expect(find.text('123'), findsAtLeastNWidgets(1)); 173 + }); 174 + 175 + testWidgets('tapping repo breadcrumb calls cubit goBackToRepo', (tester) async { 176 + const state = DevToolsState( 177 + status: DevToolsStatus.collectionLoaded, 178 + did: 'did:plc:test', 179 + handle: 'test.bsky.social', 180 + repoHandle: 'test.bsky.social', 181 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 182 + selectedCollection: 'app.bsky.feed.post', 183 + records: [ 184 + RepoListRecordsRecord( 185 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 186 + cid: 'cid123', 187 + value: {'text': 'Summary'}, 188 + ), 189 + ], 190 + ); 191 + 192 + when(() => mockDevToolsCubit.state).thenReturn(state); 193 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 194 + 195 + await tester.pumpWidget(buildSubject()); 196 + await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-repo'))); 197 + 198 + verify(() => mockDevToolsCubit.goBackToRepo()).called(1); 199 + }); 200 + 201 + testWidgets('tapping collection breadcrumb calls cubit goBackToCollection', (tester) async { 202 + const state = DevToolsState( 203 + status: DevToolsStatus.recordLoaded, 204 + did: 'did:plc:test', 205 + handle: 'test.bsky.social', 206 + repoHandle: 'test.bsky.social', 207 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 208 + selectedCollection: 'app.bsky.feed.post', 209 + selectedRecord: RecordInfo( 210 + uri: 'at://did:plc:test/app.bsky.feed.post/123', 211 + cid: 'cid123', 212 + value: {'text': 'Summary'}, 213 + ), 214 + ); 215 + 216 + when(() => mockDevToolsCubit.state).thenReturn(state); 217 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 218 + 219 + await tester.pumpWidget(buildSubject()); 220 + await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-collection'))); 221 + 222 + verify(() => mockDevToolsCubit.goBackToCollection()).called(1); 223 + }); 224 + 225 + testWidgets('shows breadcrumb progress without full-screen spinner during record navigation', (tester) async { 226 + const state = DevToolsState( 227 + status: DevToolsStatus.collectionLoaded, 228 + did: 'did:plc:test', 229 + handle: 'test.bsky.social', 230 + repoHandle: 'test.bsky.social', 231 + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 232 + selectedCollection: 'app.bsky.feed.post', 233 + records: [ 234 + RepoListRecordsRecord( 235 + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 236 + cid: 'cid123', 237 + value: {'text': 'Summary'}, 238 + ), 239 + ], 240 + isRecordLoading: true, 241 + ); 242 + 243 + when(() => mockDevToolsCubit.state).thenReturn(state); 244 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 245 + 246 + await tester.pumpWidget(buildSubject()); 247 + 248 + expect(find.byKey(const ValueKey('app-breadcrumbs-loading')), findsOneWidget); 249 + expect(find.byType(LinearProgressIndicator), findsOneWidget); 250 + expect(find.byType(CircularProgressIndicator), findsNothing); 251 + expect(find.text('123'), findsOneWidget); 145 252 }); 146 253 }); 147 254 }