diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index a21a8a0..8453a4c 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -431,8 +431,8 @@ class _MenuTile extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), leading: Icon(isSelected ? selectedIcon : icon, color: color), title: Text( - label, - style: theme.textTheme.titleMedium?.copyWith(color: color, fontWeight: FontWeight.w600), + label.toUpperCase(), + style: theme.textTheme.bodyMedium?.copyWith(color: color, fontWeight: FontWeight.w700), ), trailing: trailing, selected: isSelected, diff --git a/lib/core/widgets/app_breadcrumbs.dart b/lib/core/widgets/app_breadcrumbs.dart new file mode 100644 index 0000000..58a3ee9 --- /dev/null +++ b/lib/core/widgets/app_breadcrumbs.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +class AppBreadcrumbItem { + const AppBreadcrumbItem({required this.label, this.onTap, this.tooltip, this.key}); + + final String label; + final VoidCallback? onTap; + final String? tooltip; + final Key? key; + + bool get isCurrent => onTap == null; +} + +class AppBreadcrumbs extends StatelessWidget { + const AppBreadcrumbs({ + super.key, + required this.items, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + this.isLoading = false, + }); + + final List items; + final EdgeInsetsGeometry padding; + final bool isLoading; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: padding, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + children: [ + for (var index = 0; index < items.length; index++) ...[ + _BreadcrumbChip(item: items[index]), + if (index != items.length - 1) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant), + ), + ], + ], + ), + ), + ); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: isLoading + ? const LinearProgressIndicator(key: ValueKey('app-breadcrumbs-loading'), minHeight: 2) + : const SizedBox(key: ValueKey('app-breadcrumbs-idle'), height: 2), + ), + ], + ), + ); + } +} + +class _BreadcrumbChip extends StatelessWidget { + const _BreadcrumbChip({required this.item}); + + final AppBreadcrumbItem item; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isCurrent = item.isCurrent; + final backgroundColor = isCurrent ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest; + final foregroundColor = isCurrent ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurfaceVariant; + + final chipChild = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + item.label, + key: item.key, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith(color: foregroundColor, fontWeight: FontWeight.w600), + ), + ), + ); + + final chip = Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + child: InkWell(onTap: item.onTap, borderRadius: BorderRadius.circular(999), child: chipChild), + ); + + if ((item.tooltip ?? item.label).isEmpty) { + return chip; + } + + return Tooltip(message: item.tooltip ?? item.label, child: chip); + } +} diff --git a/lib/features/devtools/cubit/dev_tools_cubit.dart b/lib/features/devtools/cubit/dev_tools_cubit.dart index 008bc32..2dcf5d1 100644 --- a/lib/features/devtools/cubit/dev_tools_cubit.dart +++ b/lib/features/devtools/cubit/dev_tools_cubit.dart @@ -128,7 +128,7 @@ class DevToolsCubit extends Cubit { if (state.did == null) return; final collectionRequestId = _beginCollectionRequest(); - emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); + emit(state.copyWith(isCollectionLoading: true, isRecordLoading: false, errorMessage: null)); try { final response = await _repository.listRecords(repo: state.did!, collection: collection, limit: _pageSize); @@ -143,13 +143,15 @@ class DevToolsCubit extends Cubit { records: response.records, recordsCursor: response.cursor, selectedRecord: null, + isCollectionLoading: false, + isRecordLoading: false, errorMessage: null, ), ); } catch (error, stackTrace) { log.e('DevToolsCubit: Failed to load collection', error: error, stackTrace: stackTrace); if (_isActiveCollectionRequest(collectionRequestId)) { - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); + emit(state.copyWith(isCollectionLoading: false, errorMessage: _formatError(error))); } } } @@ -182,7 +184,14 @@ class DevToolsCubit extends Cubit { } catch (error, stackTrace) { log.e('DevToolsCubit: Failed to load more records', error: error, stackTrace: stackTrace); if (_isActiveCollectionRequest(activeCollectionRequestId)) { - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); + emit( + state.copyWith( + status: DevToolsStatus.collectionLoaded, + isCollectionLoading: false, + isRecordLoading: false, + errorMessage: _formatError(error), + ), + ); } } } @@ -191,7 +200,7 @@ class DevToolsCubit extends Cubit { if (state.did == null) return; final recordRequestId = _beginRecordRequest(); - emit(state.copyWith(status: DevToolsStatus.loading, errorMessage: null)); + emit(state.copyWith(isRecordLoading: true, errorMessage: null)); try { final resolvedRecord = await _repository.getRecord( @@ -211,13 +220,14 @@ class DevToolsCubit extends Cubit { cid: resolvedRecord.cid, value: resolvedRecord.value, ), + isRecordLoading: false, errorMessage: null, ), ); } catch (error, stackTrace) { log.e('DevToolsCubit: Failed to load record', error: error, stackTrace: stackTrace); if (_isActiveRecordRequest(recordRequestId)) { - emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); + emit(state.copyWith(isRecordLoading: false, errorMessage: _formatError(error))); } } } @@ -225,7 +235,14 @@ class DevToolsCubit extends Cubit { void goBackToCollection() { _recordRequestId++; if (state.selectedCollection != null) { - emit(state.copyWith(status: DevToolsStatus.collectionLoaded, selectedRecord: null)); + emit( + state.copyWith( + status: DevToolsStatus.collectionLoaded, + selectedRecord: null, + isCollectionLoading: false, + isRecordLoading: false, + ), + ); } else { emit( state.copyWith( @@ -234,6 +251,8 @@ class DevToolsCubit extends Cubit { records: null, recordsCursor: null, selectedRecord: null, + isCollectionLoading: false, + isRecordLoading: false, ), ); } @@ -249,6 +268,8 @@ class DevToolsCubit extends Cubit { records: null, recordsCursor: null, selectedRecord: null, + isCollectionLoading: false, + isRecordLoading: false, ), ); } @@ -400,6 +421,8 @@ class DevToolsCubit extends Cubit { records: records, recordsCursor: recordsCursor, selectedRecord: selectedRecord, + isCollectionLoading: false, + isRecordLoading: false, ); } diff --git a/lib/features/devtools/cubit/dev_tools_state.dart b/lib/features/devtools/cubit/dev_tools_state.dart index 8847258..d938d6a 100644 --- a/lib/features/devtools/cubit/dev_tools_state.dart +++ b/lib/features/devtools/cubit/dev_tools_state.dart @@ -52,6 +52,8 @@ class DevToolsState extends Equatable { this.records, this.recordsCursor, this.selectedRecord, + this.isCollectionLoading = false, + this.isRecordLoading = false, this.errorMessage, }); @@ -65,9 +67,12 @@ class DevToolsState extends Equatable { final List? records; final String? recordsCursor; final RecordInfo? selectedRecord; + final bool isCollectionLoading; + final bool isRecordLoading; final String? errorMessage; bool get isLoading => status == DevToolsStatus.loading || status == DevToolsStatus.loadingMore; + bool get isNavigating => isCollectionLoading || isRecordLoading; bool get hasMoreRecords => recordsCursor != null && recordsCursor!.isNotEmpty; int get totalRecords => records?.length ?? 0; int? get totalRepoRecords { @@ -92,6 +97,8 @@ class DevToolsState extends Equatable { Object? records = _devToolsStateNoChange, Object? recordsCursor = _devToolsStateNoChange, Object? selectedRecord = _devToolsStateNoChange, + bool? isCollectionLoading, + bool? isRecordLoading, Object? errorMessage = _devToolsStateNoChange, }) { return DevToolsState( @@ -109,6 +116,8 @@ class DevToolsState extends Equatable { selectedRecord: identical(selectedRecord, _devToolsStateNoChange) ? this.selectedRecord : selectedRecord as RecordInfo?, + isCollectionLoading: isCollectionLoading ?? this.isCollectionLoading, + isRecordLoading: isRecordLoading ?? this.isRecordLoading, errorMessage: identical(errorMessage, _devToolsStateNoChange) ? this.errorMessage : errorMessage as String?, ); } @@ -125,6 +134,8 @@ class DevToolsState extends Equatable { records, recordsCursor, selectedRecord, + isCollectionLoading, + isRecordLoading, errorMessage, ]; } diff --git a/lib/features/devtools/presentation/dev_tools_screen.dart b/lib/features/devtools/presentation/dev_tools_screen.dart index 5bcf38b..8eb08d6 100644 --- a/lib/features/devtools/presentation/dev_tools_screen.dart +++ b/lib/features/devtools/presentation/dev_tools_screen.dart @@ -4,6 +4,7 @@ import 'package:atproto/com_atproto_repo_listrecords.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/widgets/app_breadcrumbs.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -23,7 +24,21 @@ class DevToolsScreen extends StatelessWidget { ), ], ), - body: BlocBuilder( + body: BlocConsumer( + listenWhen: (previous, current) => + previous.errorMessage != current.errorMessage && + current.errorMessage != null && + current.status != DevToolsStatus.error, + listener: (context, state) { + final message = state.errorMessage; + if (message == null) { + return; + } + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message), behavior: SnackBarBehavior.floating)); + }, builder: (context, state) { return Column( children: [ @@ -31,7 +46,7 @@ class DevToolsScreen extends StatelessWidget { if (state.status == DevToolsStatus.repoLoaded || state.status == DevToolsStatus.collectionLoaded || state.status == DevToolsStatus.recordLoaded) - _TabBar(state: state), + _BreadcrumbBar(state: state), Expanded(child: _Content(state: state)), ], ); @@ -104,71 +119,49 @@ class _SearchInputState extends State<_SearchInput> { } } -class _TabBar extends StatelessWidget { - const _TabBar({required this.state}); +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({required this.state}); final DevToolsState state; @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Row( - children: [ - _Tab( - label: 'Repo', - isSelected: state.status == DevToolsStatus.repoLoaded, - onTap: () => context.read().goBackToRepo(), - ), - if (state.selectedCollection != null) - _Tab( - label: 'Records', - isSelected: state.status == DevToolsStatus.collectionLoaded, - onTap: () => context.read().goBackToCollection(), - ), - if (state.selectedRecord != null) - _Tab(label: 'JSON', isSelected: state.status == DevToolsStatus.recordLoaded, onTap: () {}), - ], - ), - ); + return AppBreadcrumbs(items: _items(context), isLoading: state.isNavigating); } -} -class _Tab extends StatelessWidget { - const _Tab({required this.label, required this.isSelected, required this.onTap}); - - final String label; - final bool isSelected; - final VoidCallback onTap; + List _items(BuildContext context) { + final cubit = context.read(); + final repoLabel = state.repoHandle ?? state.handle ?? state.did ?? 'Repository'; + final items = [ + AppBreadcrumbItem( + label: repoLabel, + tooltip: state.did == null ? repoLabel : '$repoLabel\n${state.did}', + key: const ValueKey('dev-tools-breadcrumb-repo'), + onTap: state.status == DevToolsStatus.repoLoaded ? null : cubit.goBackToRepo, + ), + ]; + + if (state.selectedCollection != null) { + items.add( + AppBreadcrumbItem( + label: state.selectedCollection!, + key: const ValueKey('dev-tools-breadcrumb-collection'), + onTap: state.status == DevToolsStatus.collectionLoaded ? null : cubit.goBackToCollection, + ), + ); + } - @override - Widget build(BuildContext context) { - return Expanded( - child: InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent, - width: 2, - ), - ), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.w600, - color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).textTheme.bodyMedium?.color, - ), - ), + if (state.selectedRecord != null) { + items.add( + AppBreadcrumbItem( + label: state.selectedRecord!.rkey.isEmpty ? 'Record JSON' : state.selectedRecord!.rkey, + tooltip: state.selectedRecord!.uri, + key: const ValueKey('dev-tools-breadcrumb-record'), ), - ), - ); + ); + } + + return items; } } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 1cc5f4e..af8b3f6 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -153,31 +153,31 @@ class SettingsScreen extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SegmentedButton<_AppearanceMode>( - segments: const [ - ButtonSegment(value: _AppearanceMode.system, label: Text('System')), - ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), - ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), - ], - selected: {_AppearanceMode.fromState(state)}, - onSelectionChanged: (selected) { - final mode = selected.first; - switch (mode) { - case _AppearanceMode.system: - settingsCubit.setUseSystemTheme(true); - case _AppearanceMode.light: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.light); - case _AppearanceMode.dark: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.dark); - } - }, + child: Center( + child: SegmentedButton<_AppearanceMode>( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: Theme.of(context).colorScheme.primary, + selectedForegroundColor: Theme.of(context).colorScheme.onPrimary, ), + segments: const [ + ButtonSegment(value: _AppearanceMode.system, label: Text('System')), + ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), + ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), + ], + selected: {_AppearanceMode.fromState(state)}, + onSelectionChanged: (selected) { + final mode = selected.first; + switch (mode) { + case _AppearanceMode.system: + settingsCubit.setUseSystemTheme(true); + case _AppearanceMode.light: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.light); + case _AppearanceMode.dark: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.dark); + } + }, ), ), ), diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 7417371..40d3125 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -162,11 +162,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Lazurite'), findsOneWidget); - expect(find.text('New Post'), findsOneWidget); - await tester.scrollUntilVisible(find.text('Log Out'), 200, scrollable: find.byType(Scrollable).last); - expect(find.text('Log Out'), findsOneWidget); + expect(find.text('NEW POST'), findsOneWidget); + await tester.scrollUntilVisible(find.text('LOG OUT'), 200, scrollable: find.byType(Scrollable).last); + expect(find.text('LOG OUT'), findsOneWidget); - await tester.tap(find.text('Profile').last); + await tester.tap(find.text('PROFILE').last); await tester.pumpAndSettle(); expect(find.text('RIVER TAM'), findsOneWidget); @@ -174,7 +174,7 @@ void main() { await tester.tap(find.byTooltip('Open menu')); await tester.pumpAndSettle(); - await tester.tap(find.text('Settings').last); + await tester.tap(find.text('SETTINGS').last); await tester.pumpAndSettle(); expect(find.text('APPEARANCE'), findsOneWidget); @@ -217,9 +217,9 @@ void main() { await tester.tap(find.byTooltip('Open menu')); await tester.pumpAndSettle(); - expect(find.text('Notifications'), findsOneWidget); - expect(find.text('Messages'), findsOneWidget); - expect(find.text('Settings'), findsOneWidget); + expect(find.text('NOTIFICATIONS'), findsOneWidget); + expect(find.text('MESSAGES'), findsOneWidget); + expect(find.text('SETTINGS'), findsOneWidget); }); testWidgets('tapping bottom nav tabs switches active branch', (tester) async { diff --git a/test/features/devtools/cubit/dev_tools_cubit_test.dart b/test/features/devtools/cubit/dev_tools_cubit_test.dart index 56c2b0a..bdf54e7 100644 --- a/test/features/devtools/cubit/dev_tools_cubit_test.dart +++ b/test/features/devtools/cubit/dev_tools_cubit_test.dart @@ -230,14 +230,56 @@ void main() { ), ), expect: () => [ - isA().having((state) => state.status, 'status', DevToolsStatus.loading), + isA() + .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) + .having((state) => state.isRecordLoading, 'isRecordLoading', isTrue), isA() .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) + .having((state) => state.isRecordLoading, 'isRecordLoading', isFalse) .having((state) => state.selectedRecord?.cid, 'cid', 'cid123') .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}), ], ); + blocTest( + 'loadCollection keeps repo view active while records load', + build: () { + final repository = FakeDevToolsRepository( + listRecordsHandler: + ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async { + return const RepoListRecordsOutput( + records: [ + RepoListRecordsRecord( + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), + cid: 'cid123', + value: {'text': 'Summary'}, + ), + ], + ); + }, + ); + + return DevToolsCubit(repository: repository); + }, + seed: () => const DevToolsState( + status: DevToolsStatus.repoLoaded, + did: 'did:plc:test', + repoHandle: 'test.bsky.social', + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], + ), + act: (cubit) => cubit.loadCollection('app.bsky.feed.post'), + expect: () => [ + isA() + .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) + .having((state) => state.isCollectionLoading, 'isCollectionLoading', isTrue), + isA() + .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) + .having((state) => state.isCollectionLoading, 'isCollectionLoading', isFalse) + .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post') + .having((state) => state.records?.length, 'records', 1), + ], + ); + blocTest( 'invalid AT-URI surfaces a clear error', build: () => DevToolsCubit(repository: FakeDevToolsRepository()), diff --git a/test/features/devtools/cubit/dev_tools_state_test.dart b/test/features/devtools/cubit/dev_tools_state_test.dart index dca6998..82bec99 100644 --- a/test/features/devtools/cubit/dev_tools_state_test.dart +++ b/test/features/devtools/cubit/dev_tools_state_test.dart @@ -58,6 +58,8 @@ void main() { expect(state.records, isNull); expect(state.recordsCursor, isNull); expect(state.selectedRecord, isNull); + expect(state.isCollectionLoading, isFalse); + expect(state.isRecordLoading, isFalse); expect(state.errorMessage, isNull); }); @@ -68,6 +70,12 @@ void main() { expect(const DevToolsState(status: DevToolsStatus.repoLoaded).isLoading, isFalse); }); + test('isNavigating returns true for collection or record transitions', () { + expect(const DevToolsState(isCollectionLoading: true).isNavigating, isTrue); + expect(const DevToolsState(isRecordLoading: true).isNavigating, isTrue); + expect(const DevToolsState().isNavigating, isFalse); + }); + test('hasMoreRecords returns true when cursor exists', () { expect(const DevToolsState(recordsCursor: 'cursor123').hasMoreRecords, isTrue); expect(const DevToolsState(recordsCursor: '').hasMoreRecords, isFalse); @@ -145,6 +153,8 @@ void main() { records: records, recordsCursor: 'cursor', selectedRecord: const RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/1', value: {'text': 'full'}), + isCollectionLoading: true, + isRecordLoading: true, errorMessage: 'error', ); @@ -157,6 +167,8 @@ void main() { records: null, recordsCursor: null, selectedRecord: null, + isCollectionLoading: false, + isRecordLoading: false, errorMessage: null, ); @@ -168,6 +180,8 @@ void main() { expect(updated.records, isNull); expect(updated.recordsCursor, isNull); expect(updated.selectedRecord, isNull); + expect(updated.isCollectionLoading, isFalse); + expect(updated.isRecordLoading, isFalse); expect(updated.errorMessage, isNull); }); @@ -182,10 +196,12 @@ void main() { selectedCollection: 'app.bsky.feed.post', recordsCursor: 'cursor', selectedRecord: RecordInfo(uri: 'at://test', value: {}), + isCollectionLoading: true, + isRecordLoading: true, errorMessage: 'error', ); - expect(state.props.length, 11); + expect(state.props.length, 13); expect(state.props, contains(DevToolsStatus.repoLoaded)); expect(state.props, contains(true)); expect(state.props, contains('did:plc:test')); diff --git a/test/features/devtools/presentation/dev_tools_screen_test.dart b/test/features/devtools/presentation/dev_tools_screen_test.dart index 1a7902e..b4d7635 100644 --- a/test/features/devtools/presentation/dev_tools_screen_test.dart +++ b/test/features/devtools/presentation/dev_tools_screen_test.dart @@ -102,7 +102,7 @@ void main() { await tester.pumpWidget(buildSubject()); - expect(find.text('test.bsky.social'), findsOneWidget); + expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1)); expect(find.text('did:plc:test'), findsOneWidget); expect(find.text('2 collections'), findsOneWidget); expect(find.text('5 records'), findsOneWidget); @@ -143,5 +143,112 @@ void main() { verify(() => mockDevToolsCubit.loadRecord(record)).called(1); }); + + testWidgets('renders breadcrumbs for record navigation', (tester) async { + const state = DevToolsState( + status: DevToolsStatus.recordLoaded, + did: 'did:plc:test', + handle: 'test.bsky.social', + repoHandle: 'test.bsky.social', + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], + selectedCollection: 'app.bsky.feed.post', + selectedRecord: RecordInfo( + uri: 'at://did:plc:test/app.bsky.feed.post/123', + cid: 'cid123', + value: {'text': 'Summary'}, + ), + ); + + when(() => mockDevToolsCubit.state).thenReturn(state); + whenListen(mockDevToolsCubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget(buildSubject()); + + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-repo')), findsOneWidget); + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-collection')), findsOneWidget); + expect(find.byKey(const ValueKey('dev-tools-breadcrumb-record')), findsOneWidget); + expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1)); + expect(find.text('app.bsky.feed.post'), findsAtLeastNWidgets(1)); + expect(find.text('123'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping repo breadcrumb calls cubit goBackToRepo', (tester) async { + const state = DevToolsState( + status: DevToolsStatus.collectionLoaded, + did: 'did:plc:test', + handle: 'test.bsky.social', + repoHandle: 'test.bsky.social', + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], + selectedCollection: 'app.bsky.feed.post', + records: [ + RepoListRecordsRecord( + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), + cid: 'cid123', + value: {'text': 'Summary'}, + ), + ], + ); + + when(() => mockDevToolsCubit.state).thenReturn(state); + whenListen(mockDevToolsCubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget(buildSubject()); + await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-repo'))); + + verify(() => mockDevToolsCubit.goBackToRepo()).called(1); + }); + + testWidgets('tapping collection breadcrumb calls cubit goBackToCollection', (tester) async { + const state = DevToolsState( + status: DevToolsStatus.recordLoaded, + did: 'did:plc:test', + handle: 'test.bsky.social', + repoHandle: 'test.bsky.social', + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], + selectedCollection: 'app.bsky.feed.post', + selectedRecord: RecordInfo( + uri: 'at://did:plc:test/app.bsky.feed.post/123', + cid: 'cid123', + value: {'text': 'Summary'}, + ), + ); + + when(() => mockDevToolsCubit.state).thenReturn(state); + whenListen(mockDevToolsCubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget(buildSubject()); + await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-collection'))); + + verify(() => mockDevToolsCubit.goBackToCollection()).called(1); + }); + + testWidgets('shows breadcrumb progress without full-screen spinner during record navigation', (tester) async { + const state = DevToolsState( + status: DevToolsStatus.collectionLoaded, + did: 'did:plc:test', + handle: 'test.bsky.social', + repoHandle: 'test.bsky.social', + collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], + selectedCollection: 'app.bsky.feed.post', + records: [ + RepoListRecordsRecord( + uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), + cid: 'cid123', + value: {'text': 'Summary'}, + ), + ], + isRecordLoading: true, + ); + + when(() => mockDevToolsCubit.state).thenReturn(state); + whenListen(mockDevToolsCubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget(buildSubject()); + + expect(find.byKey(const ValueKey('app-breadcrumbs-loading')), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('123'), findsOneWidget); + }); }); }