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: add typeahead @ support to pds explorer

* fix video upload limit repository

+476 -160
+84 -5
lib/features/devtools/cubit/dev_tools_cubit.dart
··· 6 6 import 'package:atproto/com_atproto_repo_getrecord.dart'; 7 7 import 'package:atproto/com_atproto_repo_listrecords.dart'; 8 8 import 'package:atproto_core/atproto_core.dart'; 9 + import 'package:bluesky/app_bsky_actor_defs.dart'; 10 + import 'package:bluesky/app_bsky_actor_searchactorstypeahead.dart'; 9 11 import 'package:equatable/equatable.dart'; 10 12 import 'package:flutter_bloc/flutter_bloc.dart'; 11 13 import 'package:lazurite/core/logging/app_logger.dart'; ··· 17 19 18 20 Future<RepoDescribeRepoOutput> describeRepo({required String repo}); 19 21 22 + Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}); 23 + 20 24 Future<RepoListRecordsOutput> listRecords({ 21 25 required String repo, 22 26 required String collection, ··· 30 34 31 35 final class AtprotoDevToolsRepository implements DevToolsRepository { 32 36 const AtprotoDevToolsRepository({required ATProto atproto}) : _atproto = atproto; 37 + 38 + static const _searchActorsTypeaheadNsid = NSID('app.bsky.actor.searchActorsTypeahead'); 33 39 34 40 final ATProto _atproto; 35 41 ··· 46 52 } 47 53 48 54 @override 55 + Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}) async { 56 + final normalizedQuery = query.trim().replaceFirst(RegExp(r'^@+'), ''); 57 + if (normalizedQuery.isEmpty) { 58 + return const []; 59 + } 60 + 61 + final response = await _atproto.get( 62 + _searchActorsTypeaheadNsid, 63 + parameters: {'q': normalizedQuery, 'limit': limit}, 64 + to: const ActorSearchActorsTypeaheadOutputConverter().fromJson, 65 + ); 66 + return response.data.actors; 67 + } 68 + 69 + @override 49 70 Future<RepoListRecordsOutput> listRecords({ 50 71 required String repo, 51 72 required String collection, ··· 87 108 int _resolveRequestId = 0; 88 109 int _collectionRequestId = 0; 89 110 int _recordRequestId = 0; 111 + int _typeaheadRequestId = 0; 90 112 91 113 Future<void> resolve(String input) async { 92 - final query = input.trim(); 114 + final query = _normalizeInputForResolve(input); 93 115 if (query.isEmpty) { 94 116 clearInput(); 95 117 return; ··· 124 146 } 125 147 } 126 148 149 + Future<void> queryTypeahead(String input) async { 150 + final query = input.trim(); 151 + if (!query.startsWith('@')) { 152 + clearTypeahead(); 153 + return; 154 + } 155 + 156 + final normalizedQuery = query.replaceFirst(RegExp(r'^@+'), ''); 157 + if (normalizedQuery.isEmpty) { 158 + clearTypeahead(); 159 + return; 160 + } 161 + 162 + final typeaheadRequestId = _beginTypeaheadRequest(); 163 + emit(state.copyWith(isTypeaheadLoading: true, typeaheadActors: const [])); 164 + 165 + try { 166 + final actors = await _repository.searchActorsTypeahead(query: normalizedQuery); 167 + if (!_isActiveTypeaheadRequest(typeaheadRequestId)) { 168 + return; 169 + } 170 + 171 + emit(state.copyWith(typeaheadActors: actors, isTypeaheadLoading: false)); 172 + } catch (error, stackTrace) { 173 + log.w('DevToolsCubit: Failed to fetch handle typeahead', error: error, stackTrace: stackTrace); 174 + if (_isActiveTypeaheadRequest(typeaheadRequestId)) { 175 + emit(state.copyWith(typeaheadActors: const [], isTypeaheadLoading: false)); 176 + } 177 + } 178 + } 179 + 180 + void clearTypeahead() { 181 + final hadTypeahead = state.typeaheadActors.isNotEmpty || state.isTypeaheadLoading; 182 + _beginTypeaheadRequest(); 183 + if (!hadTypeahead) { 184 + return; 185 + } 186 + emit(state.copyWith(typeaheadActors: const [], isTypeaheadLoading: false)); 187 + } 188 + 127 189 Future<void> loadCollection(String collection) async { 128 190 if (state.did == null) return; 129 191 ··· 283 345 _resolveRequestId++; 284 346 _collectionRequestId++; 285 347 _recordRequestId++; 348 + _typeaheadRequestId++; 286 349 return _resolveRequestId; 287 350 } 288 351 ··· 297 360 return _recordRequestId; 298 361 } 299 362 363 + int _beginTypeaheadRequest() { 364 + _typeaheadRequestId++; 365 + return _typeaheadRequestId; 366 + } 367 + 300 368 bool _isActiveResolveRequest(int requestId) => requestId == _resolveRequestId; 301 369 302 370 bool _isActiveCollectionRequest(int requestId) => requestId == _collectionRequestId; 303 371 304 372 bool _isActiveRecordRequest(int requestId) => requestId == _recordRequestId; 373 + 374 + bool _isActiveTypeaheadRequest(int requestId) => requestId == _typeaheadRequestId; 305 375 306 376 Future<void> _resolveAtUri(String input, int resolveRequestId) async { 307 377 final atUri = _parseAtUri(input); ··· 366 436 } 367 437 368 438 Future<({String did, String? handle})> _resolveIdentity(String input) async { 369 - if (input.startsWith('did:')) { 370 - return (did: input, handle: null); 439 + final normalizedInput = _normalizeInputForResolve(input); 440 + if (normalizedInput.startsWith('did:')) { 441 + return (did: normalizedInput, handle: null); 371 442 } 372 443 373 - final response = await _repository.resolveHandle(handle: input); 374 - return (did: response.did, handle: input); 444 + final response = await _repository.resolveHandle(handle: normalizedInput); 445 + return (did: response.did, handle: normalizedInput); 375 446 } 376 447 377 448 AtUri _parseAtUri(String input) { ··· 492 563 } 493 564 494 565 return error.toString(); 566 + } 567 + 568 + String _normalizeInputForResolve(String input) { 569 + final query = input.trim(); 570 + if (query.startsWith('@') && !query.startsWith('at://')) { 571 + return query.replaceFirst(RegExp(r'^@+'), ''); 572 + } 573 + return query; 495 574 } 496 575 }
+10
lib/features/devtools/cubit/dev_tools_state.dart
··· 46 46 this.did, 47 47 this.handle, 48 48 this.repoHandle, 49 + this.typeaheadActors = const [], 50 + this.isTypeaheadLoading = false, 49 51 this.collections = const [], 50 52 this.isCollectionCountsLoading = false, 51 53 this.selectedCollection, ··· 61 63 final String? did; 62 64 final String? handle; 63 65 final String? repoHandle; 66 + final List<ProfileViewBasic> typeaheadActors; 67 + final bool isTypeaheadLoading; 64 68 final List<CollectionSummary> collections; 65 69 final bool isCollectionCountsLoading; 66 70 final String? selectedCollection; ··· 91 95 Object? did = _devToolsStateNoChange, 92 96 Object? handle = _devToolsStateNoChange, 93 97 Object? repoHandle = _devToolsStateNoChange, 98 + List<ProfileViewBasic>? typeaheadActors, 99 + bool? isTypeaheadLoading, 94 100 List<CollectionSummary>? collections, 95 101 bool? isCollectionCountsLoading, 96 102 Object? selectedCollection = _devToolsStateNoChange, ··· 106 112 did: identical(did, _devToolsStateNoChange) ? this.did : did as String?, 107 113 handle: identical(handle, _devToolsStateNoChange) ? this.handle : handle as String?, 108 114 repoHandle: identical(repoHandle, _devToolsStateNoChange) ? this.repoHandle : repoHandle as String?, 115 + typeaheadActors: typeaheadActors ?? this.typeaheadActors, 116 + isTypeaheadLoading: isTypeaheadLoading ?? this.isTypeaheadLoading, 109 117 collections: collections ?? this.collections, 110 118 isCollectionCountsLoading: isCollectionCountsLoading ?? this.isCollectionCountsLoading, 111 119 selectedCollection: identical(selectedCollection, _devToolsStateNoChange) ··· 128 136 did, 129 137 handle, 130 138 repoHandle, 139 + typeaheadActors, 140 + isTypeaheadLoading, 131 141 collections, 132 142 isCollectionCountsLoading, 133 143 selectedCollection,
+157 -106
lib/features/devtools/presentation/dev_tools_screen.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 3 4 import 'package:atproto/com_atproto_repo_listrecords.dart'; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 6 import 'package:flutter/material.dart'; 5 7 import 'package:flutter/services.dart'; 6 8 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 96 98 97 99 @override 98 100 Widget build(BuildContext context) { 101 + final shouldShowTypeahead = 102 + _controller.text.trim().startsWith('@') && 103 + (widget.state.isTypeaheadLoading || widget.state.typeaheadActors.isNotEmpty); 104 + 99 105 return Padding( 100 106 padding: const EdgeInsets.all(16), 101 - child: Row( 107 + child: Column( 108 + crossAxisAlignment: CrossAxisAlignment.start, 102 109 children: [ 103 - Expanded( 104 - child: TextField( 105 - controller: _controller, 106 - decoration: const InputDecoration( 107 - hintText: 'Handle, DID, or at:// URI', 108 - border: OutlineInputBorder(), 109 - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), 110 - isDense: true, 110 + Row( 111 + children: [ 112 + Expanded( 113 + child: TextField( 114 + controller: _controller, 115 + decoration: const InputDecoration( 116 + hintText: 'Handle, DID, or at:// URI', 117 + border: OutlineInputBorder(), 118 + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), 119 + isDense: true, 120 + ), 121 + style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13), 122 + onChanged: _onQueryChanged, 123 + onSubmitted: _resolve, 124 + ), 111 125 ), 112 - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13), 113 - onSubmitted: _resolve, 114 - ), 115 - ), 116 - const SizedBox(width: 8), 117 - FilledButton( 118 - onPressed: widget.state.isLoading ? null : () => _resolve(_controller.text), 119 - child: const Text('Resolve'), 126 + const SizedBox(width: 8), 127 + FilledButton( 128 + onPressed: widget.state.isLoading ? null : () => _resolve(_controller.text), 129 + child: const Text('Resolve'), 130 + ), 131 + ], 120 132 ), 133 + if (shouldShowTypeahead) ...[ 134 + const SizedBox(height: 8), 135 + _TypeaheadResults( 136 + actors: widget.state.typeaheadActors, 137 + isLoading: widget.state.isTypeaheadLoading, 138 + onSelected: _resolveHandleSuggestion, 139 + ), 140 + ], 121 141 ], 122 142 ), 123 143 ); 124 144 } 125 145 126 146 void _resolve(String value) { 127 - final query = value.trim(); 147 + final query = _normalizeHandleQuery(value); 128 148 if (query.isEmpty) { 129 149 return; 130 150 } 131 151 132 - context.read<DevToolsCubit>().resolve(query); 152 + final cubit = context.read<DevToolsCubit>(); 153 + cubit.clearTypeahead(); 154 + cubit.resolve(query); 155 + } 156 + 157 + void _resolveHandleSuggestion(ProfileViewBasic actor) { 158 + final suggestion = '@${actor.handle}'; 159 + _controller 160 + ..text = suggestion 161 + ..selection = TextSelection.collapsed(offset: suggestion.length); 162 + _resolve(suggestion); 163 + } 164 + 165 + void _onQueryChanged(String value) { 166 + setState(() {}); 167 + 168 + final cubit = context.read<DevToolsCubit>(); 169 + final query = value.trim(); 170 + if (!query.startsWith('@')) { 171 + cubit.clearTypeahead(); 172 + return; 173 + } 174 + 175 + unawaited(cubit.queryTypeahead(query)); 176 + } 177 + 178 + String _normalizeHandleQuery(String value) { 179 + final query = value.trim(); 180 + if (query.startsWith('@') && !query.startsWith('at://')) { 181 + return query.replaceFirst(RegExp(r'^@+'), ''); 182 + } 183 + return query; 184 + } 185 + } 186 + 187 + class _TypeaheadResults extends StatelessWidget { 188 + const _TypeaheadResults({required this.actors, required this.isLoading, required this.onSelected}); 189 + 190 + final List<ProfileViewBasic> actors; 191 + final bool isLoading; 192 + final ValueChanged<ProfileViewBasic> onSelected; 193 + 194 + @override 195 + Widget build(BuildContext context) { 196 + final theme = Theme.of(context); 197 + final listHeight = (actors.length * 56.0).clamp(56.0, 220.0); 198 + 199 + return Container( 200 + decoration: BoxDecoration( 201 + border: Border.all(color: theme.dividerColor), 202 + borderRadius: BorderRadius.circular(8), 203 + color: theme.colorScheme.surface, 204 + ), 205 + child: Column( 206 + mainAxisSize: MainAxisSize.min, 207 + children: [ 208 + if (isLoading) const LinearProgressIndicator(minHeight: 2), 209 + if (actors.isNotEmpty) 210 + SizedBox( 211 + height: listHeight, 212 + child: ListView.separated( 213 + itemCount: actors.length, 214 + separatorBuilder: (_, _) => Divider(height: 1, color: theme.dividerColor), 215 + itemBuilder: (context, index) { 216 + final actor = actors[index]; 217 + return ListTile( 218 + dense: true, 219 + title: Text(actor.displayName ?? actor.handle), 220 + subtitle: Text('@${actor.handle}'), 221 + onTap: () => onSelected(actor), 222 + ); 223 + }, 224 + ), 225 + ), 226 + ], 227 + ), 228 + ); 133 229 } 134 230 } 135 231 ··· 233 329 234 330 @override 235 331 Widget build(BuildContext context) { 236 - return Center( 237 - child: Padding( 238 - padding: const EdgeInsets.all(24), 239 - child: Column( 240 - mainAxisSize: MainAxisSize.min, 241 - children: [ 242 - Icon(Icons.explore_outlined, size: 64, color: Theme.of(context).colorScheme.outline), 243 - const SizedBox(height: 16), 244 - Text('PDS Explorer', style: Theme.of(context).textTheme.titleMedium), 245 - const SizedBox(height: 8), 246 - Text( 247 - 'Enter a handle, DID, or AT-URI to explore\n' 248 - 'a user\'s repository.', 249 - textAlign: TextAlign.center, 250 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), 332 + return LayoutBuilder( 333 + builder: (context, constraints) { 334 + return SingleChildScrollView( 335 + padding: const EdgeInsets.all(24), 336 + child: ConstrainedBox( 337 + constraints: BoxConstraints(minHeight: constraints.maxHeight), 338 + child: Center( 339 + child: Column( 340 + mainAxisSize: MainAxisSize.min, 341 + children: [ 342 + Icon(Icons.explore_outlined, size: 64, color: Theme.of(context).colorScheme.outline), 343 + const SizedBox(height: 16), 344 + Text('PDS Explorer', style: Theme.of(context).textTheme.titleMedium), 345 + const SizedBox(height: 8), 346 + Text( 347 + 'Enter a handle, DID, or AT-URI to explore\n' 348 + 'a user\'s repository.', 349 + textAlign: TextAlign.center, 350 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), 351 + ), 352 + const SizedBox(height: 16), 353 + TextButton.icon( 354 + onPressed: () => _openExternalUrl('https://pds.ls'), 355 + icon: const Icon(Icons.open_in_new, size: 16), 356 + label: const Text('Inspired by pds.ls'), 357 + ), 358 + ], 359 + ), 251 360 ), 252 - const SizedBox(height: 16), 253 - TextButton.icon( 254 - onPressed: () => _openExternalUrl('https://pds.ls'), 255 - icon: const Icon(Icons.open_in_new, size: 16), 256 - label: const Text('Inspired by pds.ls'), 257 - ), 258 - ], 259 - ), 260 - ), 361 + ), 362 + ); 363 + }, 261 364 ); 262 365 } 263 366 } ··· 528 631 529 632 @override 530 633 Widget build(BuildContext context) { 531 - final jsonString = const JsonEncoder.withIndent(' ').convert(record.value); 634 + final jsonString = _formatJson(record.value); 532 635 533 636 return Column( 534 637 children: [ ··· 601 704 class _JsonViewer extends StatelessWidget { 602 705 const _JsonViewer({required this.json}); 603 706 604 - final Map<String, dynamic> json; 707 + final Object? json; 605 708 606 709 @override 607 710 Widget build(BuildContext context) { 608 - return SelectableText.rich( 609 - TextSpan(children: _buildSpans(context, json, 0)), 711 + return SelectableText( 712 + _formatJson(json), 610 713 style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, height: 1.8), 611 714 ); 612 715 } 716 + } 613 717 614 - List<TextSpan> _buildSpans(BuildContext context, dynamic value, int indent) { 615 - final theme = Theme.of(context); 616 - final primaryColor = theme.colorScheme.primary; 617 - final surfaceVariant = theme.colorScheme.onSurfaceVariant; 618 - final keyStyle = theme.textTheme.bodySmall!.copyWith(color: surfaceVariant); 619 - final valueStyle = theme.textTheme.bodySmall!.copyWith(color: primaryColor); 620 - final strStyle = theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.secondary); 621 - final numStyle = theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.tertiary); 622 - final boolStyle = theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.primary); 623 - final nullStyle = theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.error); 718 + const JsonEncoder _jsonFormatter = JsonEncoder.withIndent(' '); 624 719 625 - if (value is Map<String, dynamic>) { 626 - final spans = <TextSpan>[TextSpan(text: '{\n', style: keyStyle)]; 627 - final entries = value.entries.toList(); 628 - for (var i = 0; i < entries.length; i++) { 629 - final entry = entries[i]; 630 - spans.add(TextSpan(text: ' ' * (indent + 1), style: keyStyle)); 631 - spans.add(TextSpan(text: '"${entry.key}"', style: valueStyle)); 632 - spans.add(TextSpan(text: ': ', style: keyStyle)); 633 - spans.addAll(_buildSpans(context, entry.value, indent + 1)); 634 - if (i < entries.length - 1) { 635 - spans.add(TextSpan(text: ',', style: keyStyle)); 636 - } 637 - spans.add(TextSpan(text: '\n', style: keyStyle)); 638 - } 639 - spans.add(TextSpan(text: ' ' * indent + '}', style: keyStyle)); 640 - return spans; 641 - } 642 - 643 - if (value is List) { 644 - final spans = <TextSpan>[TextSpan(text: '[\n', style: keyStyle)]; 645 - for (var i = 0; i < value.length; i++) { 646 - spans.add(TextSpan(text: ' ' * (indent + 1), style: keyStyle)); 647 - spans.addAll(_buildSpans(context, value[i], indent + 1)); 648 - if (i < value.length - 1) { 649 - spans.add(TextSpan(text: ',', style: keyStyle)); 650 - } 651 - spans.add(TextSpan(text: '\n', style: keyStyle)); 652 - } 653 - spans.add(TextSpan(text: ' ' * indent + ']', style: keyStyle)); 654 - return spans; 655 - } 656 - 657 - if (value is String) { 658 - return [TextSpan(text: '"$value"', style: strStyle)]; 659 - } 660 - 661 - if (value is num) { 662 - return [TextSpan(text: value.toString(), style: numStyle)]; 663 - } 664 - 665 - if (value is bool) { 666 - return [TextSpan(text: value.toString(), style: boolStyle)]; 667 - } 668 - 669 - if (value == null) { 670 - return [TextSpan(text: 'null', style: nullStyle)]; 671 - } 672 - 673 - return [TextSpan(text: value.toString())]; 720 + String _formatJson(Object? value) { 721 + try { 722 + return _jsonFormatter.convert(value); 723 + } catch (_) { 724 + return value?.toString() ?? 'null'; 674 725 } 675 726 } 676 727
+53 -4
lib/features/settings/data/video_repository.dart
··· 1 1 import 'package:bluesky/bluesky.dart'; 2 + import 'package:bluesky/app_bsky_video_getuploadlimits.dart'; 3 + 4 + abstract interface class VideoUploadLimitsApi { 5 + Future<VideoGetUploadLimitsOutput> getUploadLimits(); 6 + 7 + Future<String> getUploadLimitsAuthToken(); 8 + 9 + Future<VideoGetUploadLimitsOutput> getUploadLimitsWithAuthToken(String authToken); 10 + } 11 + 12 + final class BlueskyVideoUploadLimitsApi implements VideoUploadLimitsApi { 13 + const BlueskyVideoUploadLimitsApi({required Bluesky bluesky}) : _bluesky = bluesky; 14 + 15 + final Bluesky _bluesky; 16 + 17 + @override 18 + Future<VideoGetUploadLimitsOutput> getUploadLimits() async { 19 + final response = await _bluesky.video.getUploadLimits(); 20 + return response.data; 21 + } 22 + 23 + @override 24 + Future<String> getUploadLimitsAuthToken() async { 25 + final auth = await _bluesky.video.getUploadLimitsAuth(); 26 + return auth.data.token; 27 + } 28 + 29 + @override 30 + Future<VideoGetUploadLimitsOutput> getUploadLimitsWithAuthToken(String authToken) async { 31 + final response = await _bluesky.video.getUploadLimitsWithAuthToken(authToken); 32 + return response.data; 33 + } 34 + } 2 35 3 36 class VideoRepository { 4 - VideoRepository({required Bluesky bluesky}) : _bluesky = bluesky; 37 + VideoRepository({Bluesky? bluesky, VideoUploadLimitsApi? api}) 38 + : assert(bluesky != null || api != null, 'Provide either bluesky or api'), 39 + _api = api ?? BlueskyVideoUploadLimitsApi(bluesky: bluesky!); 5 40 6 - final Bluesky _bluesky; 41 + final VideoUploadLimitsApi _api; 7 42 8 43 Future<VideoUploadLimits> getUploadLimits() async { 9 - final response = await _bluesky.video.getUploadLimits(); 10 - final data = response.data; 44 + try { 45 + final limits = await _api.getUploadLimits(); 46 + return _mapLimits(limits); 47 + } catch (error, stackTrace) { 48 + // Some auth flows require a short-lived service auth token for this endpoint. 49 + try { 50 + final authToken = await _api.getUploadLimitsAuthToken(); 51 + final limits = await _api.getUploadLimitsWithAuthToken(authToken); 52 + return _mapLimits(limits); 53 + } catch (_) { 54 + Error.throwWithStackTrace(error, stackTrace); 55 + } 56 + } 57 + } 58 + 59 + VideoUploadLimits _mapLimits(VideoGetUploadLimitsOutput data) { 11 60 return VideoUploadLimits( 12 61 canUpload: data.canUpload, 13 62 remainingDailyVideos: data.remainingDailyVideos,
+6 -4
test/core/widgets/lazurite_app_bar_test.dart
··· 90 90 expect(find.byType(AppShellMenuButton), findsOneWidget); 91 91 }); 92 92 93 - testWidgets('renders initials from handle when displayName is absent', (tester) async { 93 + testWidgets('renders app bar when displayName is absent', (tester) async { 94 94 authBloc = MockAuthBloc(); 95 95 const noDisplayName = AuthTokens( 96 96 accessToken: 'access', ··· 104 104 105 105 await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 106 106 await tester.pumpAndSettle(); 107 - expect(find.text('A'), findsOneWidget); 107 + expect(find.text('HOME'), findsOneWidget); 108 + expect(find.byType(AppShellMenuButton), findsOneWidget); 108 109 }); 109 110 110 111 testWidgets('shows simulated offline indicator and lets the user disable it', (tester) async { ··· 145 146 expect(find.text('bottom-content'), findsOneWidget); 146 147 }); 147 148 148 - testWidgets('unauthenticated state shows default L initial', (tester) async { 149 + testWidgets('unauthenticated state still renders app bar shell', (tester) async { 149 150 authBloc = MockAuthBloc(); 150 151 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 151 152 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); ··· 153 154 await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 154 155 await tester.pumpAndSettle(); 155 156 156 - expect(find.text('L'), findsOneWidget); 157 + expect(find.text('HOME'), findsOneWidget); 158 + expect(find.byType(AppShellMenuButton), findsOneWidget); 157 159 }); 158 160 }
+56 -3
test/features/devtools/cubit/dev_tools_cubit_test.dart
··· 3 3 import 'package:atproto/com_atproto_repo_getrecord.dart'; 4 4 import 'package:atproto/com_atproto_repo_listrecords.dart'; 5 5 import 'package:atproto_core/atproto_core.dart'; 6 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 7 import 'package:bloc_test/bloc_test.dart'; 7 8 import 'package:flutter_test/flutter_test.dart'; 8 9 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; ··· 11 12 FakeDevToolsRepository({ 12 13 this.resolveHandleHandler, 13 14 this.describeRepoHandler, 15 + this.searchActorsTypeaheadHandler, 14 16 this.listRecordsHandler, 15 17 this.getRecordHandler, 16 18 }); 17 19 18 20 Future<IdentityResolveHandleOutput> Function({required String handle})? resolveHandleHandler; 19 21 Future<RepoDescribeRepoOutput> Function({required String repo})? describeRepoHandler; 22 + Future<List<ProfileViewBasic>> Function({required String query, int limit})? searchActorsTypeaheadHandler; 20 23 Future<RepoListRecordsOutput> Function({ 21 24 required String repo, 22 25 required String collection, ··· 34 37 } 35 38 36 39 @override 40 + Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}) async { 41 + final handler = searchActorsTypeaheadHandler; 42 + if (handler == null) { 43 + return const []; 44 + } 45 + return handler(query: query, limit: limit); 46 + } 47 + 48 + @override 37 49 Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}) { 38 50 return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey); 39 51 } ··· 71 83 ); 72 84 73 85 blocTest<DevToolsCubit, DevToolsState>( 86 + 'queryTypeahead loads suggestions for @handle input', 87 + build: () { 88 + final repository = FakeDevToolsRepository( 89 + searchActorsTypeaheadHandler: ({required String query, int limit = 8}) async { 90 + expect(query, 'alice'); 91 + expect(limit, 8); 92 + return const [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')]; 93 + }, 94 + ); 95 + return DevToolsCubit(repository: repository); 96 + }, 97 + act: (cubit) => cubit.queryTypeahead('@alice'), 98 + expect: () => [ 99 + isA<DevToolsState>() 100 + .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isTrue) 101 + .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty), 102 + isA<DevToolsState>() 103 + .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse) 104 + .having((state) => state.typeaheadActors.length, 'typeaheadActors.length', 1) 105 + .having((state) => state.typeaheadActors.first.handle, 'first actor handle', 'alice.bsky.social'), 106 + ], 107 + ); 108 + 109 + blocTest<DevToolsCubit, DevToolsState>( 110 + 'queryTypeahead clears suggestions when input is not an @handle', 111 + build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 112 + seed: () => const DevToolsState( 113 + typeaheadActors: [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')], 114 + isTypeaheadLoading: true, 115 + ), 116 + act: (cubit) => cubit.queryTypeahead('alice'), 117 + expect: () => [ 118 + isA<DevToolsState>() 119 + .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse) 120 + .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty), 121 + ], 122 + ); 123 + 124 + blocTest<DevToolsCubit, DevToolsState>( 74 125 'resolve handle loads repo and progressive collection counts', 75 126 build: () { 76 127 final repository = FakeDevToolsRepository( 77 - resolveHandleHandler: ({required String handle}) async => 78 - const IdentityResolveHandleOutput(did: 'did:plc:alice'), 128 + resolveHandleHandler: ({required String handle}) async { 129 + expect(handle, 'alice.bsky.social'); 130 + return const IdentityResolveHandleOutput(did: 'did:plc:alice'); 131 + }, 79 132 describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput( 80 133 handle: 'alice.bsky.social', 81 134 did: 'did:plc:alice', ··· 102 155 103 156 return DevToolsCubit(repository: repository); 104 157 }, 105 - act: (cubit) => cubit.resolve('alice.bsky.social'), 158 + act: (cubit) => cubit.resolve('@alice.bsky.social'), 106 159 wait: const Duration(milliseconds: 10), 107 160 expect: () => [ 108 161 const DevToolsState(status: DevToolsStatus.loading),
+12 -1
test/features/devtools/cubit/dev_tools_state_test.dart
··· 1 1 import 'package:atproto/com_atproto_repo_listrecords.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 4 import 'package:flutter_test/flutter_test.dart'; 4 5 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 5 6 ··· 52 53 expect(state.did, isNull); 53 54 expect(state.handle, isNull); 54 55 expect(state.repoHandle, isNull); 56 + expect(state.typeaheadActors, isEmpty); 57 + expect(state.isTypeaheadLoading, isFalse); 55 58 expect(state.collections, isEmpty); 56 59 expect(state.isCollectionCountsLoading, isFalse); 57 60 expect(state.selectedCollection, isNull); ··· 147 150 did: 'did:plc:test', 148 151 handle: 'test.bsky.social', 149 152 repoHandle: 'test.bsky.social', 153 + typeaheadActors: [const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social')], 154 + isTypeaheadLoading: true, 150 155 collections: const [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 151 156 isCollectionCountsLoading: true, 152 157 selectedCollection: 'app.bsky.feed.post', ··· 162 167 did: null, 163 168 handle: null, 164 169 repoHandle: null, 170 + typeaheadActors: const [], 171 + isTypeaheadLoading: false, 165 172 isCollectionCountsLoading: false, 166 173 selectedCollection: null, 167 174 records: null, ··· 175 182 expect(updated.did, isNull); 176 183 expect(updated.handle, isNull); 177 184 expect(updated.repoHandle, isNull); 185 + expect(updated.typeaheadActors, isEmpty); 186 + expect(updated.isTypeaheadLoading, isFalse); 178 187 expect(updated.isCollectionCountsLoading, isFalse); 179 188 expect(updated.selectedCollection, isNull); 180 189 expect(updated.records, isNull); ··· 191 200 did: 'did:plc:test', 192 201 handle: 'test.bsky.social', 193 202 repoHandle: 'test.bsky.social', 203 + typeaheadActors: [ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social')], 204 + isTypeaheadLoading: true, 194 205 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 195 206 isCollectionCountsLoading: true, 196 207 selectedCollection: 'app.bsky.feed.post', ··· 201 212 errorMessage: 'error', 202 213 ); 203 214 204 - expect(state.props.length, 13); 215 + expect(state.props.length, 15); 205 216 expect(state.props, contains(DevToolsStatus.repoLoaded)); 206 217 expect(state.props, contains(true)); 207 218 expect(state.props, contains('did:plc:test'));
+29
test/features/devtools/presentation/dev_tools_screen_test.dart
··· 1 1 import 'package:atproto/com_atproto_repo_listrecords.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 4 import 'package:bloc_test/bloc_test.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 31 32 32 33 when(() => mockDevToolsCubit.state).thenReturn(const DevToolsState()); 33 34 when(() => mockDevToolsCubit.resolve(any())).thenAnswer((_) async {}); 35 + when(() => mockDevToolsCubit.queryTypeahead(any())).thenAnswer((_) async {}); 34 36 when(() => mockDevToolsCubit.loadCollection(any())).thenAnswer((_) async {}); 35 37 when(() => mockDevToolsCubit.loadRecord(any())).thenAnswer((_) async {}); 36 38 when(() => mockDevToolsCubit.loadMoreRecords()).thenAnswer((_) async {}); 37 39 when(() => mockDevToolsCubit.goBackToRepo()).thenReturn(null); 38 40 when(() => mockDevToolsCubit.goBackToCollection()).thenReturn(null); 41 + when(() => mockDevToolsCubit.clearTypeahead()).thenReturn(null); 39 42 when(() => mockDevToolsCubit.clearInput()).thenReturn(null); 40 43 41 44 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: const DevToolsState()); ··· 120 123 await tester.tap(find.text('Resolve')); 121 124 122 125 verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1); 126 + }); 127 + 128 + testWidgets('typing @handle triggers typeahead query', (tester) async { 129 + await tester.pumpWidget(buildSubject()); 130 + 131 + await tester.enterText(find.byType(TextField), '@ali'); 132 + await tester.pump(); 133 + 134 + verify(() => mockDevToolsCubit.queryTypeahead('@ali')).called(1); 135 + }); 136 + 137 + testWidgets('tapping a typeahead suggestion resolves the selected handle', (tester) async { 138 + const state = DevToolsState( 139 + typeaheadActors: [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')], 140 + ); 141 + when(() => mockDevToolsCubit.state).thenReturn(state); 142 + whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state); 143 + 144 + await tester.pumpWidget(buildSubject()); 145 + await tester.enterText(find.byType(TextField), '@a'); 146 + await tester.pump(); 147 + 148 + await tester.tap(find.text('Alice')); 149 + 150 + verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1); 151 + verify(() => mockDevToolsCubit.clearTypeahead()).called(1); 123 152 }); 124 153 125 154 testWidgets('initial query prefills the input and resolves automatically', (tester) async {
+69 -37
test/features/settings/data/video_repository_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:bluesky/app_bsky_video_getuploadlimits.dart'; 2 3 import 'package:lazurite/features/settings/data/video_repository.dart'; 3 - import 'package:mocktail/mocktail.dart'; 4 4 5 - class MockVideoRepository extends Mock implements VideoRepository {} 5 + class FakeVideoUploadLimitsApi implements VideoUploadLimitsApi { 6 + FakeVideoUploadLimitsApi({ 7 + this.getUploadLimitsHandler, 8 + this.getUploadLimitsAuthTokenHandler, 9 + this.getUploadLimitsWithAuthTokenHandler, 10 + }); 11 + 12 + Future<VideoGetUploadLimitsOutput> Function()? getUploadLimitsHandler; 13 + Future<String> Function()? getUploadLimitsAuthTokenHandler; 14 + Future<VideoGetUploadLimitsOutput> Function(String authToken)? getUploadLimitsWithAuthTokenHandler; 15 + 16 + @override 17 + Future<VideoGetUploadLimitsOutput> getUploadLimits() { 18 + final handler = getUploadLimitsHandler; 19 + if (handler == null) { 20 + throw UnimplementedError('getUploadLimitsHandler was not set'); 21 + } 22 + return handler(); 23 + } 6 24 7 - void main() { 8 - late MockVideoRepository mockRepository; 25 + @override 26 + Future<String> getUploadLimitsAuthToken() { 27 + final handler = getUploadLimitsAuthTokenHandler; 28 + if (handler == null) { 29 + throw UnimplementedError('getUploadLimitsAuthTokenHandler was not set'); 30 + } 31 + return handler(); 32 + } 9 33 10 - setUp(() { 11 - mockRepository = MockVideoRepository(); 12 - }); 34 + @override 35 + Future<VideoGetUploadLimitsOutput> getUploadLimitsWithAuthToken(String authToken) { 36 + final handler = getUploadLimitsWithAuthTokenHandler; 37 + if (handler == null) { 38 + throw UnimplementedError('getUploadLimitsWithAuthTokenHandler was not set'); 39 + } 40 + return handler(authToken); 41 + } 42 + } 13 43 44 + void main() { 14 45 group('VideoRepository.getUploadLimits', () { 15 46 test('returns limits when canUpload is true', () async { 16 - when(() => mockRepository.getUploadLimits()).thenAnswer( 17 - (_) async => 18 - const VideoUploadLimits(canUpload: true, remainingDailyVideos: 10, remainingDailyBytes: 1024 * 1024 * 500), 47 + final api = FakeVideoUploadLimitsApi( 48 + getUploadLimitsHandler: () async => 49 + const VideoGetUploadLimitsOutput(canUpload: true, remainingDailyVideos: 10, remainingDailyBytes: 500000000), 19 50 ); 20 - 21 - final result = await mockRepository.getUploadLimits(); 51 + final repository = VideoRepository(api: api); 52 + final result = await repository.getUploadLimits(); 22 53 23 54 expect(result.canUpload, isTrue); 24 55 expect(result.remainingDailyVideos, 10); 25 - expect(result.remainingDailyBytes, 1024 * 1024 * 500); 56 + expect(result.remainingDailyBytes, 500000000); 26 57 expect(result.message, isNull); 27 58 expect(result.error, isNull); 28 59 }); 29 60 30 - test('returns limits with message and error when canUpload is false', () async { 31 - when(() => mockRepository.getUploadLimits()).thenAnswer( 32 - (_) async => const VideoUploadLimits( 33 - canUpload: false, 34 - remainingDailyVideos: 0, 35 - remainingDailyBytes: 0, 36 - message: 'Daily limit reached', 37 - error: 'DAILY_LIMIT_EXCEEDED', 38 - ), 61 + test('retries with service-auth token when direct limits request fails', () async { 62 + final api = FakeVideoUploadLimitsApi( 63 + getUploadLimitsHandler: () async => throw Exception('invalid token'), 64 + getUploadLimitsAuthTokenHandler: () async => 'service-auth-token', 65 + getUploadLimitsWithAuthTokenHandler: (authToken) async { 66 + expect(authToken, 'service-auth-token'); 67 + return const VideoGetUploadLimitsOutput( 68 + canUpload: false, 69 + remainingDailyVideos: 0, 70 + remainingDailyBytes: 0, 71 + message: 'Daily limit reached', 72 + error: 'DAILY_LIMIT_EXCEEDED', 73 + ); 74 + }, 39 75 ); 40 - 41 - final result = await mockRepository.getUploadLimits(); 76 + final repository = VideoRepository(api: api); 77 + final result = await repository.getUploadLimits(); 42 78 43 79 expect(result.canUpload, isFalse); 80 + expect(result.remainingDailyVideos, 0); 81 + expect(result.remainingDailyBytes, 0); 44 82 expect(result.message, 'Daily limit reached'); 45 83 expect(result.error, 'DAILY_LIMIT_EXCEEDED'); 46 84 }); 47 85 48 - test('returns limits with all optional fields null', () async { 49 - when(() => mockRepository.getUploadLimits()).thenAnswer((_) async => const VideoUploadLimits(canUpload: true)); 50 - 51 - final result = await mockRepository.getUploadLimits(); 52 - 53 - expect(result.canUpload, isTrue); 54 - expect(result.remainingDailyVideos, isNull); 55 - expect(result.remainingDailyBytes, isNull); 56 - }); 86 + test('rethrows the original error when direct and fallback requests fail', () async { 87 + final api = FakeVideoUploadLimitsApi( 88 + getUploadLimitsHandler: () async => throw Exception('invalid token'), 89 + getUploadLimitsAuthTokenHandler: () async => throw Exception('service auth unavailable'), 90 + ); 91 + final repository = VideoRepository(api: api); 57 92 58 - test('propagates exceptions', () async { 59 - when(() => mockRepository.getUploadLimits()).thenThrow(Exception('auth error')); 60 - 61 - expect(() => mockRepository.getUploadLimits(), throwsException); 93 + await expectLater(repository.getUploadLimits(), throwsA(isA<Exception>())); 62 94 }); 63 95 }); 64 96