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.

fix: image (blob) uploading & more comfortable composer UI (#24)

* fix: make image upload use blob records

* feat: show video and image in alt text ui

* feat: unauthorized session recovery in ComposeRepository

* feat: unauthorized session recovery in more repos

* feat: composer avatar loading and display in compose screen

* feat: autofocus composer field

* chore: remove debugPrint

authored by

Owais and committed by
GitHub
c32639f5 ccf34790

+1723 -393
+4 -2
lib/core/router/app_router.dart
··· 15 15 import 'package:lazurite/core/router/app_shell.dart'; 16 16 import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 17 17 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 18 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 18 19 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 19 20 import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 20 21 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; ··· 79 80 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 80 81 81 82 class AppRouter { 82 - AppRouter({required this.authBloc, this.navigatorObserver}); 83 + AppRouter({required this.authBloc, this.navigatorObserver, this.onUnauthorized}); 83 84 final AuthBloc authBloc; 84 85 final NavigatorObserver? navigatorObserver; 86 + final Future<AuthTokens?> Function()? onUnauthorized; 85 87 final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root'); 86 88 final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home'); 87 89 final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); ··· 218 220 state, 219 221 BlocProvider( 220 222 create: (_) => ComposeBloc( 221 - composeRepository: ComposeRepository(bluesky: context.read<Bluesky>()), 223 + composeRepository: ComposeRepository(bluesky: context.read<Bluesky>(), onUnauthorized: onUnauthorized), 222 224 database: context.read<AppDatabase>(), 223 225 accountDid: context.read<String>(), 224 226 ),
+3 -3
lib/core/scheduler/post_scheduler.dart
··· 151 151 continue; 152 152 } 153 153 154 - final blobRef = await repo.uploadBlob(bytes.toList(), mimeType: mime); 155 - if (blobRef == null) { 154 + final blob = await repo.uploadBlobRecord(bytes.toList(), mimeType: mime); 155 + if (blob == null) { 156 156 throw Exception('Failed to upload image ${paths[i]}'); 157 157 } 158 158 159 159 final altText = i < alts.length ? alts[i] : ''; 160 - final entry = <String, dynamic>{'image': blobRef.toJson(), 'alt': altText}; 160 + final entry = <String, dynamic>{'image': blob.toJson(), 'alt': altText}; 161 161 162 162 try { 163 163 final dims = await readImageDimensions(bytes.toList());
+3 -2
lib/features/auth/presentation/login_screen.dart
··· 7 7 import 'package:flutter_svg/flutter_svg.dart'; 8 8 import 'package:go_router/go_router.dart'; 9 9 import 'package:lazurite/core/database/app_database.dart'; 10 + import 'package:lazurite/core/logging/app_logger.dart'; 10 11 import 'package:lazurite/core/network/app_view_provider.dart'; 11 12 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 12 13 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; ··· 47 48 return context.read<AccountSwitcherCubit>(); 48 49 } catch (_) { 49 50 if (kDebugMode && !_didLogMissingAccountSwitcherProvider) { 50 - debugPrint('LoginScreen: AccountSwitcherCubit unavailable for login route.'); 51 + log.d('LoginScreen: AccountSwitcherCubit unavailable for login route.'); 51 52 _didLogMissingAccountSwitcherProvider = true; 52 53 } 53 54 return null; ··· 281 282 return null; 282 283 } catch (_) { 283 284 if (kDebugMode && !_didLogAvatarLookupFailure) { 284 - debugPrint('LoginScreen: cached avatar lookup unavailable.'); 285 + log.d('LoginScreen: cached avatar lookup unavailable.'); 285 286 _didLogAvatarLookupFailure = true; 286 287 } 287 288 return null;
+65 -41
lib/features/compose/bloc/compose_bloc.dart
··· 4 4 import 'dart:ui' as ui; 5 5 6 6 import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef, XRPCException; 7 - import 'package:bluesky/bluesky.dart'; 8 7 import 'package:bluesky/app_bsky_video_defs.dart' show KnownJobStatusState; 9 8 import 'package:bluesky_text/bluesky_text.dart'; 10 9 import 'package:characters/characters.dart'; ··· 14 13 import 'package:lazurite/core/database/app_database.dart'; 15 14 import 'package:lazurite/core/logging/app_logger.dart'; 16 15 import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 16 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 17 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 17 18 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 19 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 18 20 import 'package:lazurite/features/compose/data/link_preview_service.dart'; 19 21 20 22 part 'compose_event.dart'; ··· 483 485 return; 484 486 } 485 487 486 - final blob = await _composeRepository.uploadBlob(bytes, mimeType: mime); 488 + final blob = await _composeRepository.uploadBlobRecord(bytes, mimeType: mime); 487 489 if (blob == null) { 488 490 _emitError(emit, 'Failed to upload image. Please try again.'); 489 491 return; 490 492 } 491 493 uploaded.add( 492 - _UploadedImage( 493 - blobRef: blob, 494 - altText: attachment.altText, 495 - width: attachment.width, 496 - height: attachment.height, 497 - ), 494 + _UploadedImage(blob: blob, altText: attachment.altText, width: attachment.width, height: attachment.height), 498 495 ); 499 496 } 500 497 501 498 mediaEmbed = { 502 499 '\$type': 'app.bsky.embed.images', 503 500 'images': uploaded.map((img) { 504 - final entry = <String, dynamic>{'image': img.blobRef.toJson(), 'alt': img.altText}; 501 + final entry = <String, dynamic>{'image': img.blob.toJson(), 'alt': img.altText}; 505 502 if (img.width != null && img.height != null) { 506 503 entry['aspectRatio'] = {'width': img.width, 'height': img.height}; 507 504 } ··· 627 624 ); 628 625 await _database.saveDraft(draft); 629 626 _emitError(emit, 'Network error — post saved as draft.'); 630 - } catch (_) { 627 + } catch (draftError, stackTrace) { 628 + log.e('Failed to save failed post submission as draft', error: draftError, stackTrace: stackTrace); 631 629 _emitError(emit, 'Failed to submit post: $error'); 632 630 } 633 631 } ··· 679 677 } 680 678 681 679 class _UploadedImage { 682 - const _UploadedImage({required this.blobRef, required this.altText, this.width, this.height}); 680 + const _UploadedImage({required this.blob, required this.altText, this.width, this.height}); 683 681 684 - final BlobRef blobRef; 682 + final Blob blob; 685 683 final String altText; 686 684 final int? width; 687 685 final int? height; ··· 701 699 702 700 class ComposeRepository { 703 701 ComposeRepository({ 704 - required Bluesky bluesky, 702 + required dynamic bluesky, 705 703 LinkPreviewService? linkPreviewService, 706 704 ActorRepositoryServiceResolver? actorRepositoryServiceResolver, 705 + Future<AuthTokens?> Function()? onUnauthorized, 706 + dynamic Function(AuthTokens tokens)? blueskyClientFactory, 707 707 }) : _actorRepoResolver = actorRepositoryServiceResolver ?? ActorRepositoryServiceResolver(), 708 - _bluesky = bluesky, 709 - _linkPreviewService = linkPreviewService ?? LinkPreviewService(); 708 + _linkPreviewService = linkPreviewService ?? LinkPreviewService() { 709 + _authRecovery = UnauthorizedRecoveryRunner<dynamic>( 710 + initialClient: bluesky, 711 + onUnauthorized: onUnauthorized, 712 + clientFactory: blueskyClientFactory ?? createBlueskyClient, 713 + onUnauthorizedException: (error, stackTrace) { 714 + log.w('compose.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); 715 + }, 716 + ); 717 + } 710 718 711 - final Bluesky _bluesky; 719 + late final UnauthorizedRecoveryRunner<dynamic> _authRecovery; 720 + dynamic get _bluesky => _authRecovery.client; 712 721 final LinkPreviewService _linkPreviewService; 713 722 final ActorRepositoryServiceResolver _actorRepoResolver; 714 723 715 - Future<BlobRef?> uploadBlob(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 724 + Future<Blob?> uploadBlobRecord(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 716 725 try { 717 - final response = await _bluesky.atproto.repo.uploadBlob( 718 - bytes: Uint8List.fromList(bytes), 719 - $headers: {'Content-Type': mimeType}, 726 + final response = await _authRecovery.run( 727 + (client) => 728 + client.atproto.repo.uploadBlob(bytes: Uint8List.fromList(bytes), $headers: {'Content-Type': mimeType}), 720 729 ); 721 - return response.data.blob.ref; 730 + return response.data.blob; 722 731 } catch (e, stackTrace) { 723 732 log.e('Failed to upload blob', error: e, stackTrace: stackTrace); 724 733 return null; 725 734 } 726 735 } 727 736 737 + Future<BlobRef?> uploadBlob(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 738 + final blob = await uploadBlobRecord(bytes, mimeType: mimeType); 739 + return blob?.ref; 740 + } 741 + 728 742 /// Uploads video bytes and returns the job ID, or null on failure. 729 743 Future<String?> uploadVideo(Uint8List bytes) async { 730 744 try { 731 - final response = await _bluesky.video.uploadVideo(bytes: bytes); 745 + final response = await _authRecovery.run((client) => client.video.uploadVideo(bytes: bytes)); 732 746 return response.data.jobId; 733 747 } catch (e, stackTrace) { 734 748 log.e('Failed to upload video', error: e, stackTrace: stackTrace); ··· 738 752 739 753 Future<dynamic> getJobStatus(String jobId) async { 740 754 try { 741 - final response = await _bluesky.video.getJobStatus(jobId: jobId); 755 + final response = await _authRecovery.run((client) => client.video.getJobStatus(jobId: jobId)); 742 756 return response.data.jobStatus; 743 757 } catch (e, stackTrace) { 744 758 log.e('Failed to get job status', error: e, stackTrace: stackTrace); ··· 748 762 749 763 Future<({bool canUpload, String? message})?> getUploadLimits() async { 750 764 try { 751 - final response = await _bluesky.video.getUploadLimits(); 765 + final response = await _authRecovery.run((client) => client.video.getUploadLimits()); 752 766 final d = response.data; 753 - return (canUpload: d.canUpload, message: d.message ?? d.error); 767 + final canUpload = d.canUpload; 768 + final message = d.message ?? d.error; 769 + return (canUpload: canUpload == true, message: message is String ? message : null); 754 770 } catch (e, stackTrace) { 755 771 log.e('Failed to get upload limits', error: e, stackTrace: stackTrace); 756 772 return null; ··· 775 791 if (embed != null) record['embed'] = embed; 776 792 if (reply != null) record['reply'] = reply; 777 793 778 - await _bluesky.atproto.repo.createRecord(repo: repo, collection: 'app.bsky.feed.post', record: record); 794 + await _authRecovery.run( 795 + (client) => client.atproto.repo.createRecord(repo: repo, collection: 'app.bsky.feed.post', record: record), 796 + ); 779 797 return true; 780 798 } catch (e, stackTrace) { 781 799 log.e('Failed to create post', error: e, stackTrace: stackTrace); ··· 857 875 } 858 876 } 859 877 860 - Future<BlobRef?> _uploadExternalThumb(String thumbUrl) async { 878 + Future<Blob?> _uploadExternalThumb(String thumbUrl) async { 861 879 try { 862 880 final thumb = await _linkPreviewService.fetchThumbnail(thumbUrl); 863 881 if (thumb == null) { 864 882 return null; 865 883 } 866 - return await uploadBlob(thumb.bytes, mimeType: thumb.mimeType); 884 + return await uploadBlobRecord(thumb.bytes, mimeType: thumb.mimeType); 867 885 } catch (error, stackTrace) { 868 886 log.w('Failed to upload external embed thumbnail blob', error: error, stackTrace: stackTrace); 869 887 return null; ··· 906 924 } 907 925 updatedRecord[r'$type'] = 'app.bsky.feed.post'; 908 926 909 - await _bluesky.atproto.repo.deleteRecord( 910 - repo: targetRepo, 911 - collection: collection, 912 - rkey: rkey, 913 - swapRecord: swapCid, 927 + await _authRecovery.run( 928 + (client) => 929 + client.atproto.repo.deleteRecord(repo: targetRepo, collection: collection, rkey: rkey, swapRecord: swapCid), 914 930 ); 915 931 916 932 late final String newCid; 917 933 try { 918 - final created = await _bluesky.atproto.repo.createRecord( 919 - repo: targetRepo, 920 - collection: collection, 921 - rkey: rkey, 922 - record: updatedRecord, 934 + final created = await _authRecovery.run( 935 + (client) => client.atproto.repo.createRecord( 936 + repo: targetRepo, 937 + collection: collection, 938 + rkey: rkey, 939 + record: updatedRecord, 940 + ), 923 941 ); 924 942 newCid = created.data.cid; 925 943 } on XRPCException catch (e, stackTrace) { ··· 1021 1039 } 1022 1040 1023 1041 try { 1024 - await _bluesky.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord); 1042 + await _authRecovery.run( 1043 + (client) => 1044 + client.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord), 1045 + ); 1025 1046 return true; 1026 1047 } catch (e, stackTrace) { 1027 1048 log.e('Failed to restore original record after edit failure', error: e, stackTrace: stackTrace); ··· 1031 1052 1032 1053 Future<dynamic> _getRecordFromRepo({required String repo, required String collection, required String rkey}) async { 1033 1054 final serviceHost = await _resolveRepoServiceHost(repo); 1034 - return _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey, $service: serviceHost); 1055 + return _authRecovery.run( 1056 + (client) => client.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey, $service: serviceHost), 1057 + ); 1035 1058 } 1036 1059 1037 1060 Future<String?> _resolveRepoServiceHost(String repo) async { ··· 1077 1100 final result = (width: image.width, height: image.height); 1078 1101 image.dispose(); 1079 1102 return result; 1080 - } catch (_) { 1103 + } catch (error, stackTrace) { 1104 + log.w('Failed to read image dimensions', error: error, stackTrace: stackTrace); 1081 1105 return null; 1082 1106 } 1083 1107 }
+850 -269
lib/features/compose/presentation/compose_screen.dart
··· 2 2 import 'dart:convert'; 3 3 import 'dart:io'; 4 4 import 'dart:ui' as ui; 5 - import 'package:lazurite/core/theme/theme_extensions.dart'; 6 5 7 6 import 'package:bluesky_text/bluesky_text.dart'; 7 + import 'package:flutter/foundation.dart'; 8 8 import 'package:flutter/material.dart'; 9 9 import 'package:flutter_bloc/flutter_bloc.dart'; 10 10 import 'package:image_picker/image_picker.dart'; 11 11 import 'package:intl/intl.dart'; 12 + import 'package:lazurite/core/database/app_database.dart'; 13 + import 'package:lazurite/core/logging/app_logger.dart'; 14 + import 'package:lazurite/core/theme/theme_extensions.dart'; 15 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 16 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 13 17 import 'package:lazurite/features/compose/data/link_preview_service.dart'; 14 18 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 15 19 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 20 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 16 21 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 17 22 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 18 23 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 19 24 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 20 25 import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; 26 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 27 + import 'package:video_player/video_player.dart'; 21 28 22 29 class ComposeScreen extends StatefulWidget { 23 30 const ComposeScreen({ ··· 79 86 bool _isLoadingLinkPreview = false; 80 87 String? _hiddenPreviewUrl; 81 88 bool _showDrafts = false; 89 + String? _composerAvatarDid; 90 + Future<String?>? _composerAvatarFuture; 91 + bool _didLogMissingAuthProviderForAvatar = false; 92 + bool _didLogMissingProfileRepositoryForAvatar = false; 93 + bool _didLogComposerAvatarLookupFailure = false; 82 94 83 95 @override 84 96 void initState() { ··· 147 159 _mentionDebounce?.cancel(); 148 160 _linkPreviewDebounce?.cancel(); 149 161 super.dispose(); 162 + } 163 + 164 + @override 165 + void didChangeDependencies() { 166 + super.didChangeDependencies(); 167 + _syncComposerAvatarFuture(); 168 + } 169 + 170 + void _syncComposerAvatarFuture() { 171 + AuthState? authState; 172 + try { 173 + authState = context.read<AuthBloc>().state; 174 + } catch (_) { 175 + if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { 176 + log.d('ComposeScreen: auth provider unavailable for composer avatar.'); 177 + _didLogMissingAuthProviderForAvatar = true; 178 + } 179 + } 180 + 181 + final did = authState?.tokens?.did.trim(); 182 + if (did == null || did.isEmpty) { 183 + _composerAvatarDid = null; 184 + _composerAvatarFuture = null; 185 + return; 186 + } 187 + 188 + if (_composerAvatarDid == did && _composerAvatarFuture != null) { 189 + return; 190 + } 191 + 192 + _composerAvatarDid = did; 193 + _composerAvatarFuture = _loadComposerAvatarUrl(did); 194 + } 195 + 196 + Future<String?> _loadComposerAvatarUrl(String did) async { 197 + ProfileRepository repository; 198 + try { 199 + repository = context.read<ProfileRepository>(); 200 + } catch (_) { 201 + if (kDebugMode && !_didLogMissingProfileRepositoryForAvatar) { 202 + log.d('ComposeScreen: profile repository unavailable for composer avatar.'); 203 + _didLogMissingProfileRepositoryForAvatar = true; 204 + } 205 + return null; 206 + } 207 + 208 + try { 209 + final profile = await repository.getProfile(did); 210 + return profile.avatar; 211 + } catch (_) { 212 + if (kDebugMode && !_didLogComposerAvatarLookupFailure) { 213 + log.d('ComposeScreen: composer avatar lookup failed.'); 214 + _didLogComposerAvatarLookupFailure = true; 215 + } 216 + return null; 217 + } 150 218 } 151 219 152 220 void _onTextChanged() { ··· 410 478 } 411 479 } 412 480 413 - Future<void> _showVideoAltTextDialog(String currentAltText) async { 414 - final TextEditingController altController = TextEditingController(text: currentAltText); 415 - 481 + Future<void> _showVideoAltTextDialog(VideoAttachment video) async { 416 482 final result = await showDialog<String>( 417 483 context: context, 418 - builder: (dialogContext) => ConfirmationDialog( 419 - title: const Text('Add video alt text'), 420 - content: TextField( 421 - controller: altController, 422 - maxLines: 3, 423 - maxLength: 1000, 424 - decoration: const InputDecoration( 425 - hintText: 'Describe the video for accessibility', 426 - border: OutlineInputBorder(), 427 - ), 428 - ), 429 - confirmLabel: 'Save', 484 + builder: (dialogContext) => _VideoAltTextDialog( 485 + video: video, 430 486 onCancel: () => Navigator.pop(dialogContext), 431 - onConfirm: () => Navigator.pop(dialogContext, altController.text), 487 + onSave: (altText) => Navigator.pop(dialogContext, altText), 432 488 ), 433 489 ); 434 - 435 - altController.dispose(); 436 490 437 491 if (result != null && mounted) { 438 492 context.read<ComposeBloc>().add(VideoAltTextUpdated(result)); 439 493 } 440 494 } 441 495 442 - Future<void> _showAltTextDialog(int index, String currentAltText) async { 443 - final TextEditingController altController = TextEditingController(text: currentAltText); 444 - 496 + Future<void> _showAltTextDialog(int index, MediaAttachment attachment) async { 445 497 final result = await showDialog<String>( 446 498 context: context, 447 - builder: (dialogContext) => ConfirmationDialog( 448 - title: const Text('Add alt text'), 449 - content: TextField( 450 - controller: altController, 451 - maxLines: 3, 452 - maxLength: 1000, 453 - decoration: const InputDecoration( 454 - hintText: 'Describe the image for visually impaired users', 455 - border: OutlineInputBorder(), 456 - ), 457 - ), 458 - confirmLabel: 'Save', 499 + builder: (dialogContext) => _ImageAltTextDialog( 500 + imagePath: attachment.localPath, 501 + initialAltText: attachment.altText, 459 502 onCancel: () => Navigator.pop(dialogContext), 460 - onConfirm: () => Navigator.pop(dialogContext, altController.text), 503 + onSave: (altText) => Navigator.pop(dialogContext, altText), 461 504 ), 462 505 ); 463 - 464 - altController.dispose(); 465 506 466 507 if (result != null && mounted) { 467 508 context.read<ComposeBloc>().add(AltTextUpdated(index: index, altText: result)); ··· 538 579 Widget _buildDraftsPanel() { 539 580 return BlocBuilder<ComposeBloc, ComposeState>( 540 581 builder: (context, state) { 541 - return Container( 542 - constraints: const BoxConstraints(maxHeight: 280), 543 - decoration: BoxDecoration( 544 - border: Border(top: BorderSide(color: theme.dividerColor)), 545 - ), 546 - child: Column( 547 - mainAxisSize: MainAxisSize.min, 548 - children: [ 549 - Padding( 550 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 551 - child: Row( 552 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 553 - children: [ 554 - Text('Drafts', style: theme.textTheme.titleMedium), 555 - if (state.drafts.isNotEmpty) 556 - Text( 557 - '${state.drafts.length} draft${state.drafts.length != 1 ? 's' : ''}', 558 - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 559 - ), 560 - ], 582 + final colorScheme = theme.colorScheme; 583 + return Column( 584 + mainAxisSize: MainAxisSize.min, 585 + children: [ 586 + Container( 587 + height: 8, 588 + decoration: BoxDecoration( 589 + color: colorScheme.surfaceContainerLow, 590 + border: Border( 591 + top: BorderSide(color: colorScheme.outlineVariant), 592 + bottom: BorderSide(color: colorScheme.outlineVariant), 561 593 ), 562 594 ), 563 - if (state.isLoadingDrafts) 564 - const Padding( 565 - padding: EdgeInsets.symmetric(vertical: 24), 566 - child: Center(child: CircularProgressIndicator()), 567 - ) 568 - else if (state.drafts.isEmpty) 569 - Padding( 570 - padding: const EdgeInsets.symmetric(vertical: 24), 571 - child: Center( 572 - child: Text( 573 - 'No drafts saved', 574 - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), 595 + ), 596 + Container( 597 + constraints: const BoxConstraints(maxHeight: 292), 598 + decoration: BoxDecoration( 599 + color: colorScheme.surface, 600 + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 601 + ), 602 + child: Column( 603 + mainAxisSize: MainAxisSize.min, 604 + children: [ 605 + Padding( 606 + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), 607 + child: Row( 608 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 609 + children: [ 610 + Text('Drafts', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 611 + Text( 612 + '${state.drafts.length} draft${state.drafts.length == 1 ? '' : 's'}', 613 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 614 + ), 615 + ], 575 616 ), 576 617 ), 577 - ) 578 - else 579 - Flexible( 580 - child: ListView.builder( 581 - shrinkWrap: true, 582 - itemCount: state.drafts.length, 583 - itemBuilder: (context, index) { 584 - final draft = state.drafts[index]; 585 - return ListTile( 586 - dense: true, 587 - title: Text( 588 - draft.content.isEmpty ? '(No text)' : draft.content, 589 - maxLines: 2, 590 - overflow: TextOverflow.ellipsis, 591 - ), 592 - subtitle: Row( 593 - children: [ 594 - Text(_formatDraftTime(draft.updatedAt), style: theme.textTheme.bodySmall), 595 - if (draft.scheduledAt != null) ...[ 596 - const SizedBox(width: 8), 597 - Container( 598 - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 599 - decoration: BoxDecoration( 600 - color: theme.colorScheme.primaryContainer, 601 - borderRadius: BorderRadius.circular(4), 602 - ), 603 - child: Text( 604 - 'Scheduled', 605 - style: theme.textTheme.bodySmall?.copyWith( 606 - color: theme.colorScheme.onPrimaryContainer, 607 - ), 608 - ), 609 - ), 610 - ], 611 - ], 618 + if (state.isLoadingDrafts) 619 + const Padding( 620 + padding: EdgeInsets.symmetric(vertical: 26), 621 + child: Center(child: CircularProgressIndicator()), 622 + ) 623 + else if (state.drafts.isEmpty) 624 + Padding( 625 + padding: const EdgeInsets.symmetric(vertical: 26), 626 + child: Center( 627 + child: Text( 628 + 'No drafts saved', 629 + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 612 630 ), 613 - trailing: IconButton( 614 - icon: Icon(Icons.delete_outline, color: theme.colorScheme.error), 615 - onPressed: () { 616 - final bloc = context.read<ComposeBloc>(); 617 - showConfirmationDialog( 618 - context: context, 619 - title: const Text('Delete Draft?'), 620 - content: const Text('This action cannot be undone.'), 621 - confirmLabel: 'Delete', 622 - confirmDestructive: true, 623 - ).then((confirmed) { 624 - if (confirmed && mounted) { 625 - bloc.add(DraftDeleted(draft.id)); 626 - } 627 - }); 628 - }, 629 - ), 630 - onTap: () { 631 - setState(() => _showDrafts = false); 632 - context.read<ComposeBloc>().add(DraftLoaded(draft.id)); 631 + ), 632 + ) 633 + else 634 + Flexible( 635 + child: ListView.separated( 636 + shrinkWrap: true, 637 + itemCount: state.drafts.length, 638 + separatorBuilder: (_, _) => Divider(height: 1, color: colorScheme.outlineVariant), 639 + itemBuilder: (context, index) { 640 + final draft = state.drafts[index]; 641 + return _DraftListItem( 642 + draft: draft, 643 + formattedTime: _formatDraftTime(draft.updatedAt), 644 + onTap: () { 645 + setState(() => _showDrafts = false); 646 + context.read<ComposeBloc>().add(DraftLoaded(draft.id)); 647 + }, 648 + onDelete: () { 649 + final bloc = context.read<ComposeBloc>(); 650 + showConfirmationDialog( 651 + context: context, 652 + title: const Text('Delete Draft?'), 653 + content: const Text('This action cannot be undone.'), 654 + confirmLabel: 'Delete', 655 + confirmDestructive: true, 656 + ).then((confirmed) { 657 + if (confirmed && mounted) { 658 + bloc.add(DraftDeleted(draft.id)); 659 + } 660 + }); 661 + }, 662 + ); 633 663 }, 634 - ); 635 - }, 636 - ), 637 - ), 638 - ], 639 - ), 664 + ), 665 + ), 666 + ], 667 + ), 668 + ), 669 + ], 640 670 ); 641 671 }, 642 672 ); ··· 750 780 } 751 781 752 782 return Padding( 753 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 783 + padding: const EdgeInsets.fromLTRB(68, 0, 16, 8), 754 784 child: ExternalLinkPreviewCard( 755 785 uri: _linkPreview!.uri, 756 786 title: _linkPreview!.title, ··· 838 868 } 839 869 } 840 870 871 + String _composerAvatarFallbackText() { 872 + try { 873 + final tokens = context.read<AuthBloc>().state.tokens; 874 + final displayName = tokens?.displayName?.trim(); 875 + if (displayName != null && displayName.isNotEmpty) { 876 + return displayName; 877 + } 878 + 879 + final handle = tokens?.handle.trim(); 880 + if (handle != null && handle.isNotEmpty) { 881 + return handle; 882 + } 883 + } catch (_) { 884 + if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { 885 + log.d('ComposeScreen: auth provider unavailable for avatar fallback.'); 886 + _didLogMissingAuthProviderForAvatar = true; 887 + } 888 + } 889 + 890 + return 'Lazurite'; 891 + } 892 + 893 + Widget _buildComposerAvatar() { 894 + final avatar = ProfileAvatar( 895 + key: const ValueKey('compose_author_avatar'), 896 + size: 40, 897 + imageUrl: null, 898 + fallbackText: _composerAvatarFallbackText(), 899 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 900 + placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( 901 + color: theme.colorScheme.onSurface, 902 + fontWeight: FontWeight.w700, 903 + ), 904 + ); 905 + 906 + final avatarFuture = _composerAvatarFuture; 907 + if (avatarFuture == null) { 908 + return avatar; 909 + } 910 + 911 + return FutureBuilder<String?>( 912 + future: avatarFuture, 913 + builder: (context, snapshot) { 914 + return ProfileAvatar( 915 + key: const ValueKey('compose_author_avatar'), 916 + size: 40, 917 + imageUrl: snapshot.data, 918 + fallbackText: _composerAvatarFallbackText(), 919 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 920 + placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( 921 + color: theme.colorScheme.onSurface, 922 + fontWeight: FontWeight.w700, 923 + ), 924 + ); 925 + }, 926 + ); 927 + } 928 + 929 + Widget _buildComposerTextArea() { 930 + return Expanded( 931 + child: Padding( 932 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 933 + child: Row( 934 + crossAxisAlignment: CrossAxisAlignment.start, 935 + children: [ 936 + _buildComposerAvatar(), 937 + const SizedBox(width: 12), 938 + Expanded( 939 + child: TextField( 940 + controller: _textController, 941 + focusNode: _textFocusNode, 942 + autofocus: true, 943 + maxLines: null, 944 + expands: true, 945 + textAlignVertical: TextAlignVertical.top, 946 + decoration: InputDecoration( 947 + hintText: "What's on your mind?", 948 + hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), 949 + border: InputBorder.none, 950 + contentPadding: EdgeInsets.zero, 951 + ), 952 + style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), 953 + ), 954 + ), 955 + ], 956 + ), 957 + ), 958 + ); 959 + } 960 + 961 + Widget _buildScheduledPill(ComposeState state) { 962 + if (!state.hasScheduledTime) { 963 + return const SizedBox.shrink(); 964 + } 965 + 966 + final colorScheme = theme.colorScheme; 967 + return Align( 968 + alignment: Alignment.centerLeft, 969 + child: Container( 970 + margin: const EdgeInsets.fromLTRB(68, 4, 16, 12), 971 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), 972 + decoration: BoxDecoration( 973 + color: colorScheme.primary, 974 + borderRadius: BorderRadius.circular(999), 975 + border: Border.all(color: colorScheme.primary), 976 + ), 977 + child: Row( 978 + mainAxisSize: MainAxisSize.min, 979 + children: [ 980 + Icon(Icons.schedule, size: 15, color: colorScheme.onPrimary), 981 + const SizedBox(width: 7), 982 + Text( 983 + 'Scheduled for ${DateFormat('MMM d, h:mm a').format(state.scheduledAt!)}', 984 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onPrimary, fontWeight: FontWeight.w600), 985 + ), 986 + const SizedBox(width: 2), 987 + IconButton( 988 + onPressed: () => context.read<ComposeBloc>().add(const ScheduleCleared()), 989 + icon: Icon(Icons.close, size: 16, color: colorScheme.onPrimary), 990 + tooltip: 'Clear scheduled time', 991 + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), 992 + padding: EdgeInsets.zero, 993 + visualDensity: VisualDensity.compact, 994 + style: IconButton.styleFrom( 995 + tapTargetSize: MaterialTapTargetSize.shrinkWrap, 996 + minimumSize: const Size(40, 40), 997 + ), 998 + ), 999 + ], 1000 + ), 1001 + ), 1002 + ); 1003 + } 1004 + 1005 + Widget _buildToolbarIconButton({ 1006 + required IconData icon, 1007 + required String tooltip, 1008 + required VoidCallback? onPressed, 1009 + bool isActive = false, 1010 + }) { 1011 + final colorScheme = theme.colorScheme; 1012 + final foregroundColor = onPressed == null 1013 + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) 1014 + : colorScheme.primary; 1015 + 1016 + return IconButton( 1017 + onPressed: onPressed, 1018 + icon: Icon(icon, color: foregroundColor), 1019 + tooltip: tooltip, 1020 + style: IconButton.styleFrom( 1021 + fixedSize: const Size(40, 40), 1022 + minimumSize: const Size(40, 40), 1023 + padding: EdgeInsets.zero, 1024 + backgroundColor: isActive ? colorScheme.surfaceContainerLow : Colors.transparent, 1025 + shape: const CircleBorder(), 1026 + ), 1027 + ); 1028 + } 1029 + 841 1030 @override 842 1031 Widget build(BuildContext context) { 843 1032 return BlocListener<ComposeBloc, ComposeState>( ··· 875 1064 }, 876 1065 child: Scaffold( 877 1066 appBar: AppBar( 1067 + elevation: 0, 1068 + scrolledUnderElevation: 0, 1069 + backgroundColor: theme.colorScheme.surface, 1070 + surfaceTintColor: Colors.transparent, 1071 + shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), 878 1072 leading: TextButton(onPressed: () => _handleBackNavigation(context), child: const Text('Cancel')), 879 1073 leadingWidth: 80, 880 1074 title: BlocBuilder<ComposeBloc, ComposeState>( 881 - builder: (context, state) => Text(state.isEditing ? 'Edit Post' : 'New Post'), 1075 + builder: (context, state) => Text( 1076 + state.isEditing ? 'Edit Post' : 'New Post', 1077 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 1078 + ), 882 1079 ), 883 1080 centerTitle: true, 884 1081 actions: [ 885 1082 BlocBuilder<ComposeBloc, ComposeState>( 886 - builder: (context, state) => state.isEditing 887 - ? const SizedBox.shrink() 888 - : TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), 889 - ), 890 - BlocBuilder<ComposeBloc, ComposeState>( 891 1083 builder: (context, state) { 892 1084 final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 893 - final button = TextButton( 1085 + final button = FilledButton( 894 1086 onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, 1087 + style: FilledButton.styleFrom( 1088 + minimumSize: const Size(64, 36), 1089 + padding: const EdgeInsets.symmetric(horizontal: 18), 1090 + visualDensity: VisualDensity.compact, 1091 + ), 895 1092 child: state.isSubmitting 896 - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 1093 + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) 897 1094 : Text(state.isEditing ? 'Save Changes' : 'Post'), 898 1095 ); 899 1096 ··· 978 1175 ); 979 1176 }, 980 1177 ), 981 - Expanded( 982 - child: Padding( 983 - padding: const EdgeInsets.all(16), 984 - child: TextField( 985 - controller: _textController, 986 - focusNode: _textFocusNode, 987 - maxLines: null, 988 - expands: true, 989 - textAlignVertical: TextAlignVertical.top, 990 - decoration: const InputDecoration( 991 - hintText: "What's on your mind?", 992 - border: InputBorder.none, 993 - contentPadding: EdgeInsets.zero, 994 - ), 995 - style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 17), 996 - ), 997 - ), 998 - ), 1178 + _buildComposerTextArea(), 999 1179 _buildMentionAutocompletePanel(), 1000 1180 BlocBuilder<ComposeBloc, ComposeState>(builder: (context, state) => _buildComposerLinkPreview(state)), 1001 1181 if (_isLoadingLinkPreview) ··· 1003 1183 padding: EdgeInsets.only(bottom: 8), 1004 1184 child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), 1005 1185 ), 1006 - BlocBuilder<ComposeBloc, ComposeState>( 1007 - builder: (context, state) { 1008 - if (!state.hasScheduledTime) return const SizedBox.shrink(); 1009 - 1010 - return Container( 1011 - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 1012 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 1013 - decoration: BoxDecoration( 1014 - color: theme.colorScheme.primaryContainer, 1015 - borderRadius: BorderRadius.circular(20), 1016 - ), 1017 - child: Row( 1018 - mainAxisSize: MainAxisSize.min, 1019 - children: [ 1020 - Icon(Icons.schedule, size: 16, color: theme.colorScheme.onPrimaryContainer), 1021 - const SizedBox(width: 8), 1022 - Text( 1023 - 'Scheduled for ${DateFormat('MMM d, h:mm a').format(state.scheduledAt!)}', 1024 - style: Theme.of( 1025 - context, 1026 - ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onPrimaryContainer), 1027 - ), 1028 - const SizedBox(width: 8), 1029 - GestureDetector( 1030 - onTap: () { 1031 - context.read<ComposeBloc>().add(const ScheduleCleared()); 1032 - }, 1033 - child: Icon(Icons.close, size: 16, color: theme.colorScheme.onPrimaryContainer), 1034 - ), 1035 - ], 1036 - ), 1037 - ); 1038 - }, 1039 - ), 1186 + BlocBuilder<ComposeBloc, ComposeState>(builder: (context, state) => _buildScheduledPill(state)), 1040 1187 1041 1188 BlocBuilder<ComposeBloc, ComposeState>( 1042 1189 builder: (context, state) { ··· 1045 1192 } 1046 1193 if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); 1047 1194 1048 - return Container( 1049 - height: 120, 1050 - padding: const EdgeInsets.symmetric(horizontal: 16), 1051 - child: ListView.builder( 1052 - scrollDirection: Axis.horizontal, 1195 + return Padding( 1196 + padding: const EdgeInsets.fromLTRB(68, 0, 16, 16), 1197 + child: GridView.builder( 1198 + shrinkWrap: true, 1199 + physics: const NeverScrollableScrollPhysics(), 1053 1200 itemCount: state.mediaAttachments.length, 1201 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 1202 + crossAxisCount: 2, 1203 + mainAxisSpacing: 8, 1204 + crossAxisSpacing: 8, 1205 + childAspectRatio: 4 / 3, 1206 + ), 1054 1207 itemBuilder: (context, index) { 1055 1208 final attachment = state.mediaAttachments[index]; 1056 - return Padding( 1057 - padding: const EdgeInsets.only(right: 8), 1058 - child: Stack( 1059 - children: [ 1060 - ClipRRect( 1209 + return Stack( 1210 + fit: StackFit.expand, 1211 + children: [ 1212 + Positioned.fill( 1213 + child: DecoratedBox( 1214 + decoration: BoxDecoration( 1215 + color: theme.colorScheme.surfaceContainerLow, 1216 + borderRadius: BorderRadius.circular(12), 1217 + ), 1218 + child: const SizedBox.expand(), 1219 + ), 1220 + ), 1221 + Positioned.fill( 1222 + child: ClipRRect( 1061 1223 borderRadius: BorderRadius.circular(12), 1062 - child: Image.file( 1063 - File(attachment.localPath), 1064 - width: 120, 1065 - height: 120, 1066 - fit: BoxFit.cover, 1067 - ), 1224 + child: Image.file(File(attachment.localPath), fit: BoxFit.cover), 1068 1225 ), 1069 - Positioned( 1070 - left: 8, 1071 - bottom: 8, 1072 - child: GestureDetector( 1073 - onTap: () => _showAltTextDialog(index, attachment.altText), 1074 - child: Container( 1075 - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 1076 - decoration: BoxDecoration( 1077 - color: attachment.altText.isNotEmpty 1078 - ? theme.colorScheme.primary 1079 - : Colors.black54, 1080 - borderRadius: BorderRadius.circular(4), 1081 - ), 1082 - child: Text( 1083 - 'ALT', 1084 - style: theme.textTheme.labelSmall?.copyWith( 1085 - color: attachment.altText.isNotEmpty 1086 - ? theme.colorScheme.onPrimary 1087 - : Colors.white, 1088 - fontWeight: FontWeight.bold, 1226 + ), 1227 + Positioned( 1228 + left: 6, 1229 + bottom: 6, 1230 + child: Material( 1231 + color: attachment.altText.isNotEmpty ? theme.colorScheme.primary : Colors.black54, 1232 + borderRadius: BorderRadius.circular(4), 1233 + child: InkWell( 1234 + borderRadius: BorderRadius.circular(4), 1235 + onTap: () => _showAltTextDialog(index, attachment), 1236 + child: ConstrainedBox( 1237 + constraints: const BoxConstraints(minWidth: 40, minHeight: 30), 1238 + child: Center( 1239 + child: Text( 1240 + 'ALT', 1241 + style: theme.textTheme.labelSmall?.copyWith( 1242 + color: attachment.altText.isNotEmpty 1243 + ? theme.colorScheme.onPrimary 1244 + : Colors.white, 1245 + fontWeight: FontWeight.bold, 1246 + ), 1089 1247 ), 1090 1248 ), 1091 1249 ), 1092 1250 ), 1093 1251 ), 1094 - Positioned( 1095 - top: 4, 1096 - right: 4, 1097 - child: GestureDetector( 1098 - onTap: () { 1099 - context.read<ComposeBloc>().add(MediaRemoved(index)); 1100 - }, 1101 - child: Container( 1102 - padding: const EdgeInsets.all(4), 1103 - decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 1104 - child: const Icon(Icons.close, size: 16, color: Colors.white), 1105 - ), 1252 + ), 1253 + Positioned( 1254 + top: 6, 1255 + right: 6, 1256 + child: SizedBox( 1257 + width: 32, 1258 + height: 32, 1259 + child: IconButton( 1260 + padding: EdgeInsets.zero, 1261 + style: IconButton.styleFrom(backgroundColor: Colors.black54), 1262 + onPressed: () => context.read<ComposeBloc>().add(MediaRemoved(index)), 1263 + icon: const Icon(Icons.close, size: 16, color: Colors.white), 1264 + tooltip: 'Remove image', 1106 1265 ), 1107 1266 ), 1108 - ], 1109 - ), 1267 + ), 1268 + ], 1110 1269 ); 1111 1270 }, 1112 1271 ), ··· 1122 1281 if (video == null) return const SizedBox.shrink(); 1123 1282 1124 1283 return Container( 1125 - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 1284 + margin: const EdgeInsets.fromLTRB(68, 0, 16, 16), 1126 1285 padding: const EdgeInsets.all(12), 1127 1286 decoration: BoxDecoration( 1128 - color: theme.colorScheme.surfaceContainerHighest, 1287 + color: theme.colorScheme.surfaceContainerLow, 1129 1288 borderRadius: BorderRadius.circular(12), 1130 1289 border: Border.all(color: video.hasError ? theme.colorScheme.error : theme.dividerColor), 1131 1290 ), ··· 1174 1333 ? theme.colorScheme.error 1175 1334 : theme.colorScheme.onSurfaceVariant, 1176 1335 ), 1336 + maxLines: 2, 1337 + overflow: TextOverflow.ellipsis, 1177 1338 ), 1178 1339 if (video.isActive && video.uploadProgress > 0) ...[ 1179 1340 const SizedBox(height: 4), ··· 1189 1350 IconButton( 1190 1351 icon: const Icon(Icons.subtitles_outlined), 1191 1352 tooltip: 'Add alt text', 1192 - onPressed: () => _showVideoAltTextDialog(video.altText), 1353 + onPressed: () => _showVideoAltTextDialog(video), 1193 1354 color: video.altText.isNotEmpty 1194 1355 ? theme.colorScheme.primary 1195 1356 : theme.colorScheme.onSurfaceVariant, ··· 1212 1373 ? const SizedBox.shrink() 1213 1374 : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), 1214 1375 ), 1215 - const SizedBox(height: 8), 1216 1376 Container( 1217 1377 decoration: BoxDecoration( 1218 - border: Border(top: BorderSide(color: theme.dividerColor)), 1378 + color: theme.colorScheme.surface, 1379 + border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), 1219 1380 ), 1220 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 1381 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 1221 1382 child: SafeArea( 1383 + top: false, 1222 1384 child: Row( 1223 1385 children: [ 1224 1386 BlocBuilder<ComposeBloc, ComposeState>( 1225 1387 builder: (context, state) { 1226 1388 if (state.isEditing) return const SizedBox.shrink(); 1227 - return IconButton( 1228 - onPressed: state.canAddMoreMedia ? _pickImage : null, 1229 - icon: Icon( 1230 - Icons.image_outlined, 1231 - color: state.canAddMoreMedia 1232 - ? theme.colorScheme.primary 1233 - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 1234 - ), 1389 + return _buildToolbarIconButton( 1390 + icon: Icons.image_outlined, 1235 1391 tooltip: 'Add image', 1392 + onPressed: state.canAddMoreMedia ? _pickImage : null, 1236 1393 ); 1237 1394 }, 1238 1395 ), 1239 1396 BlocBuilder<ComposeBloc, ComposeState>( 1240 1397 builder: (context, state) { 1241 1398 if (state.isEditing) return const SizedBox.shrink(); 1242 - return IconButton( 1399 + return _buildToolbarIconButton( 1400 + icon: Icons.videocam_outlined, 1401 + tooltip: 'Add video', 1243 1402 onPressed: state.canAddVideo ? _pickVideo : null, 1244 - icon: Icon( 1245 - Icons.videocam_outlined, 1246 - color: state.canAddVideo 1247 - ? theme.colorScheme.primary 1248 - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), 1249 - ), 1250 - tooltip: 'Add video', 1403 + ); 1404 + }, 1405 + ), 1406 + BlocBuilder<ComposeBloc, ComposeState>( 1407 + builder: (context, state) { 1408 + if (state.isEditing) return const SizedBox.shrink(); 1409 + final hasDraftableContent = 1410 + state.text.trim().isNotEmpty || 1411 + state.hasMedia || 1412 + state.hasVideo || 1413 + state.hasScheduledTime; 1414 + return _buildToolbarIconButton( 1415 + icon: Icons.save_outlined, 1416 + tooltip: 'Save draft', 1417 + onPressed: hasDraftableContent ? _saveDraft : null, 1251 1418 ); 1252 1419 }, 1253 1420 ), 1254 1421 BlocBuilder<ComposeBloc, ComposeState>( 1255 1422 builder: (context, state) { 1256 1423 if (state.isEditing) return const SizedBox.shrink(); 1257 - return IconButton( 1424 + return _buildToolbarIconButton( 1425 + icon: Icons.drive_file_rename_outline, 1426 + tooltip: 'Drafts', 1258 1427 onPressed: _toggleDrafts, 1259 - icon: Icon(Icons.drive_file_rename_outline, color: theme.colorScheme.primary), 1260 - tooltip: 'Drafts', 1428 + isActive: _showDrafts, 1261 1429 ); 1262 1430 }, 1263 1431 ), 1264 1432 BlocBuilder<ComposeBloc, ComposeState>( 1265 1433 builder: (context, state) { 1266 1434 if (state.isEditing) return const SizedBox.shrink(); 1267 - return IconButton( 1435 + return _buildToolbarIconButton( 1436 + icon: Icons.schedule, 1437 + tooltip: 'Schedule', 1268 1438 onPressed: _showSchedulePicker, 1269 - icon: Icon(Icons.schedule, color: theme.colorScheme.primary), 1270 - tooltip: 'Schedule', 1439 + isActive: state.hasScheduledTime, 1271 1440 ); 1272 1441 }, 1273 1442 ), ··· 1290 1459 } 1291 1460 } 1292 1461 1462 + class _DraftListItem extends StatelessWidget { 1463 + const _DraftListItem({required this.draft, required this.formattedTime, required this.onTap, required this.onDelete}); 1464 + 1465 + final DraftEntry draft; 1466 + final String formattedTime; 1467 + final VoidCallback onTap; 1468 + final VoidCallback onDelete; 1469 + 1470 + @override 1471 + Widget build(BuildContext context) { 1472 + final theme = Theme.of(context); 1473 + final colorScheme = theme.colorScheme; 1474 + 1475 + return InkWell( 1476 + onTap: onTap, 1477 + child: Padding( 1478 + padding: const EdgeInsets.fromLTRB(16, 11, 8, 11), 1479 + child: Row( 1480 + crossAxisAlignment: CrossAxisAlignment.start, 1481 + children: [ 1482 + Expanded( 1483 + child: Column( 1484 + crossAxisAlignment: CrossAxisAlignment.start, 1485 + children: [ 1486 + Text( 1487 + draft.content.isEmpty ? '(No text)' : draft.content, 1488 + maxLines: 2, 1489 + overflow: TextOverflow.ellipsis, 1490 + style: theme.textTheme.bodyMedium?.copyWith(height: 1.35), 1491 + ), 1492 + const SizedBox(height: 6), 1493 + Row( 1494 + children: [ 1495 + Text( 1496 + formattedTime, 1497 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 1498 + ), 1499 + if (draft.scheduledAt != null) ...[ 1500 + const SizedBox(width: 8), 1501 + Container( 1502 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 1503 + decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), 1504 + child: Text( 1505 + 'Scheduled', 1506 + style: theme.textTheme.labelSmall?.copyWith( 1507 + color: colorScheme.onPrimary, 1508 + fontWeight: FontWeight.w700, 1509 + ), 1510 + ), 1511 + ), 1512 + ], 1513 + ], 1514 + ), 1515 + ], 1516 + ), 1517 + ), 1518 + IconButton( 1519 + icon: Icon(Icons.delete_outline, color: colorScheme.error), 1520 + onPressed: onDelete, 1521 + tooltip: 'Delete draft', 1522 + visualDensity: VisualDensity.compact, 1523 + ), 1524 + ], 1525 + ), 1526 + ), 1527 + ); 1528 + } 1529 + } 1530 + 1293 1531 /// A [TextEditingController] that highlights AT Protocol facets (mentions, 1294 1532 /// links, hashtags) inline as the user types. 1295 1533 /// ··· 1349 1587 if (byteOffset <= 0) return 0; 1350 1588 if (byteOffset >= textBytes.length) return utf8.decode(textBytes, allowMalformed: true).length; 1351 1589 return utf8.decode(textBytes.sublist(0, byteOffset), allowMalformed: true).length; 1590 + } 1591 + } 1592 + 1593 + class _ImageAltTextDialog extends StatefulWidget { 1594 + const _ImageAltTextDialog({ 1595 + required this.imagePath, 1596 + required this.initialAltText, 1597 + required this.onCancel, 1598 + required this.onSave, 1599 + }); 1600 + 1601 + final String imagePath; 1602 + final String initialAltText; 1603 + final VoidCallback onCancel; 1604 + final ValueChanged<String> onSave; 1605 + 1606 + @override 1607 + State<_ImageAltTextDialog> createState() => _ImageAltTextDialogState(); 1608 + } 1609 + 1610 + class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { 1611 + late final TextEditingController _controller = TextEditingController(text: widget.initialAltText); 1612 + 1613 + @override 1614 + void dispose() { 1615 + _controller.dispose(); 1616 + super.dispose(); 1617 + } 1618 + 1619 + @override 1620 + Widget build(BuildContext context) { 1621 + final theme = Theme.of(context); 1622 + final colorScheme = theme.colorScheme; 1623 + final size = MediaQuery.sizeOf(context); 1624 + final imageHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); 1625 + 1626 + return Dialog( 1627 + clipBehavior: Clip.antiAlias, 1628 + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), 1629 + child: ConstrainedBox( 1630 + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), 1631 + child: SingleChildScrollView( 1632 + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), 1633 + child: Column( 1634 + mainAxisSize: MainAxisSize.min, 1635 + crossAxisAlignment: CrossAxisAlignment.stretch, 1636 + children: [ 1637 + Row( 1638 + children: [ 1639 + Expanded(child: Text('Alt text', style: theme.textTheme.titleLarge)), 1640 + IconButton(tooltip: 'Close', onPressed: widget.onCancel, icon: const Icon(Icons.close)), 1641 + ], 1642 + ), 1643 + const SizedBox(height: 12), 1644 + Container( 1645 + height: imageHeight, 1646 + clipBehavior: Clip.antiAlias, 1647 + decoration: BoxDecoration( 1648 + color: colorScheme.surfaceContainerHighest, 1649 + borderRadius: BorderRadius.circular(10), 1650 + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), 1651 + ), 1652 + child: Image.file( 1653 + key: const ValueKey('alt-text-image-preview'), 1654 + File(widget.imagePath), 1655 + fit: BoxFit.contain, 1656 + errorBuilder: (context, error, stackTrace) => 1657 + Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), 1658 + ), 1659 + ), 1660 + const SizedBox(height: 16), 1661 + TextField( 1662 + key: const ValueKey('alt-text-field'), 1663 + controller: _controller, 1664 + minLines: 3, 1665 + maxLines: 5, 1666 + maxLength: 1000, 1667 + textInputAction: TextInputAction.newline, 1668 + decoration: const InputDecoration(hintText: 'Describe the image', border: OutlineInputBorder()), 1669 + ), 1670 + const SizedBox(height: 8), 1671 + Row( 1672 + mainAxisAlignment: MainAxisAlignment.end, 1673 + children: [ 1674 + TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), 1675 + const SizedBox(width: 8), 1676 + FilledButton(onPressed: () => widget.onSave(_controller.text), child: const Text('Save')), 1677 + ], 1678 + ), 1679 + ], 1680 + ), 1681 + ), 1682 + ), 1683 + ); 1684 + } 1685 + } 1686 + 1687 + class _VideoAltTextDialog extends StatefulWidget { 1688 + const _VideoAltTextDialog({required this.video, required this.onCancel, required this.onSave}); 1689 + 1690 + final VideoAttachment video; 1691 + final VoidCallback onCancel; 1692 + final ValueChanged<String> onSave; 1693 + 1694 + @override 1695 + State<_VideoAltTextDialog> createState() => _VideoAltTextDialogState(); 1696 + } 1697 + 1698 + class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { 1699 + late final TextEditingController _controller = TextEditingController(text: widget.video.altText); 1700 + 1701 + @override 1702 + void dispose() { 1703 + _controller.dispose(); 1704 + super.dispose(); 1705 + } 1706 + 1707 + @override 1708 + Widget build(BuildContext context) { 1709 + final theme = Theme.of(context); 1710 + final size = MediaQuery.sizeOf(context); 1711 + final previewHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); 1712 + 1713 + return Dialog( 1714 + clipBehavior: Clip.antiAlias, 1715 + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), 1716 + child: ConstrainedBox( 1717 + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), 1718 + child: SingleChildScrollView( 1719 + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), 1720 + child: Column( 1721 + mainAxisSize: MainAxisSize.min, 1722 + crossAxisAlignment: CrossAxisAlignment.stretch, 1723 + children: [ 1724 + Row( 1725 + children: [ 1726 + Expanded(child: Text('Video alt text', style: theme.textTheme.titleLarge)), 1727 + IconButton(tooltip: 'Close', onPressed: widget.onCancel, icon: const Icon(Icons.close)), 1728 + ], 1729 + ), 1730 + const SizedBox(height: 12), 1731 + _LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), 1732 + const SizedBox(height: 16), 1733 + TextField( 1734 + key: const ValueKey('video-alt-text-field'), 1735 + controller: _controller, 1736 + minLines: 3, 1737 + maxLines: 5, 1738 + maxLength: 1000, 1739 + textInputAction: TextInputAction.newline, 1740 + decoration: const InputDecoration(hintText: 'Describe the video', border: OutlineInputBorder()), 1741 + ), 1742 + const SizedBox(height: 8), 1743 + Row( 1744 + mainAxisAlignment: MainAxisAlignment.end, 1745 + children: [ 1746 + TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), 1747 + const SizedBox(width: 8), 1748 + FilledButton(onPressed: () => widget.onSave(_controller.text), child: const Text('Save')), 1749 + ], 1750 + ), 1751 + ], 1752 + ), 1753 + ), 1754 + ), 1755 + ); 1756 + } 1757 + } 1758 + 1759 + class _LocalVideoPreview extends StatefulWidget { 1760 + const _LocalVideoPreview({required this.videoPath, required this.height}); 1761 + 1762 + final String videoPath; 1763 + final double height; 1764 + 1765 + @override 1766 + State<_LocalVideoPreview> createState() => _LocalVideoPreviewState(); 1767 + } 1768 + 1769 + class _LocalVideoPreviewState extends State<_LocalVideoPreview> { 1770 + VideoPlayerController? _controller; 1771 + Object? _error; 1772 + bool _isInitializing = false; 1773 + 1774 + @override 1775 + void initState() { 1776 + super.initState(); 1777 + _initialize(); 1778 + } 1779 + 1780 + @override 1781 + void dispose() { 1782 + _controller?.dispose(); 1783 + super.dispose(); 1784 + } 1785 + 1786 + @override 1787 + Widget build(BuildContext context) { 1788 + final theme = Theme.of(context); 1789 + final colorScheme = theme.colorScheme; 1790 + final controller = _controller; 1791 + final filename = widget.videoPath.split('/').last; 1792 + 1793 + return Container( 1794 + key: const ValueKey('video-alt-preview'), 1795 + height: widget.height, 1796 + clipBehavior: Clip.antiAlias, 1797 + decoration: BoxDecoration( 1798 + color: colorScheme.surfaceContainerHighest, 1799 + borderRadius: BorderRadius.circular(10), 1800 + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), 1801 + ), 1802 + child: GestureDetector( 1803 + onTap: controller != null && controller.value.isInitialized ? _togglePlayback : null, 1804 + child: Stack( 1805 + fit: StackFit.expand, 1806 + alignment: Alignment.center, 1807 + children: [ 1808 + if (controller != null && controller.value.isInitialized) 1809 + FittedBox( 1810 + fit: BoxFit.contain, 1811 + child: SizedBox( 1812 + width: controller.value.size.width, 1813 + height: controller.value.size.height, 1814 + child: VideoPlayer(controller), 1815 + ), 1816 + ) 1817 + else 1818 + _VideoPreviewFallback(filename: filename, isLoading: _isInitializing, error: _error), 1819 + Center( 1820 + child: Container( 1821 + width: 56, 1822 + height: 56, 1823 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle), 1824 + child: Icon( 1825 + controller?.value.isPlaying == true ? Icons.pause : Icons.play_arrow, 1826 + color: Colors.white, 1827 + size: 30, 1828 + ), 1829 + ), 1830 + ), 1831 + ], 1832 + ), 1833 + ), 1834 + ); 1835 + } 1836 + 1837 + Future<void> _initialize() async { 1838 + final file = File(widget.videoPath); 1839 + if (!file.existsSync()) { 1840 + return; 1841 + } 1842 + 1843 + setState(() { 1844 + _isInitializing = true; 1845 + }); 1846 + 1847 + try { 1848 + final controller = VideoPlayerController.file(file); 1849 + await controller.initialize(); 1850 + await controller.setLooping(true); 1851 + 1852 + if (!mounted) { 1853 + await controller.dispose(); 1854 + return; 1855 + } 1856 + 1857 + setState(() { 1858 + _controller = controller; 1859 + _isInitializing = false; 1860 + }); 1861 + } catch (error) { 1862 + if (!mounted) { 1863 + return; 1864 + } 1865 + setState(() { 1866 + _error = error; 1867 + _isInitializing = false; 1868 + }); 1869 + } 1870 + } 1871 + 1872 + Future<void> _togglePlayback() async { 1873 + final controller = _controller; 1874 + if (controller == null || !controller.value.isInitialized) { 1875 + return; 1876 + } 1877 + 1878 + if (controller.value.isPlaying) { 1879 + await controller.pause(); 1880 + } else { 1881 + await controller.play(); 1882 + } 1883 + 1884 + if (mounted) { 1885 + setState(() {}); 1886 + } 1887 + } 1888 + } 1889 + 1890 + class _VideoPreviewFallback extends StatelessWidget { 1891 + const _VideoPreviewFallback({required this.filename, required this.isLoading, required this.error}); 1892 + 1893 + final String filename; 1894 + final bool isLoading; 1895 + final Object? error; 1896 + 1897 + @override 1898 + Widget build(BuildContext context) { 1899 + final theme = Theme.of(context); 1900 + final colorScheme = theme.colorScheme; 1901 + 1902 + return Center( 1903 + child: Padding( 1904 + padding: const EdgeInsets.all(20), 1905 + child: Column( 1906 + mainAxisSize: MainAxisSize.min, 1907 + children: [ 1908 + if (isLoading) 1909 + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.4)) 1910 + else 1911 + Icon(Icons.videocam_outlined, size: 40, color: colorScheme.onSurfaceVariant), 1912 + const SizedBox(height: 10), 1913 + Text( 1914 + filename.isEmpty ? 'Video' : filename, 1915 + key: const ValueKey('video-alt-preview-filename'), 1916 + style: theme.textTheme.bodyMedium, 1917 + maxLines: 1, 1918 + overflow: TextOverflow.ellipsis, 1919 + textAlign: TextAlign.center, 1920 + ), 1921 + if (error != null) ...[ 1922 + const SizedBox(height: 4), 1923 + Text( 1924 + 'Preview unavailable', 1925 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 1926 + textAlign: TextAlign.center, 1927 + ), 1928 + ], 1929 + ], 1930 + ), 1931 + ), 1932 + ); 1352 1933 } 1353 1934 } 1354 1935
+9 -2
lib/features/notifications/cubit/unread_count_cubit.dart
··· 13 13 }) : _notificationDomainService = 14 14 notificationDomainService ?? 15 15 NotificationDomainService( 16 - notificationRepository: notificationRepository ?? 16 + notificationRepository: 17 + notificationRepository ?? 17 18 (throw ArgumentError('Either notificationDomainService or notificationRepository is required')), 18 19 ), 19 - super(const UnreadCountState(0)) { 20 + super(const UnreadCountState(0)) { 20 21 _startPolling(); 21 22 } 22 23 ··· 33 34 Future<void> _pollUnreadCount() async { 34 35 try { 35 36 final count = await _notificationDomainService.getUnreadCount(); 37 + if (isClosed) { 38 + return; 39 + } 36 40 emit(UnreadCountState(count)); 37 41 } catch (_) { 42 + if (isClosed) { 43 + return; 44 + } 38 45 log.w('Failed to poll unread count'); 39 46 } 40 47 }
+58 -41
lib/features/notifications/data/notification_repository.dart
··· 2 2 import 'package:bluesky/app_bsky_notification_registerpush.dart'; 3 3 import 'package:bluesky/app_bsky_notification_unregisterpush.dart'; 4 4 import 'package:bluesky/bluesky.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 5 6 import 'package:lazurite/core/network/app_view_request_context.dart'; 7 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 8 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 10 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 11 8 12 class NotificationRepository { ··· 11 15 ModerationService? moderationService, 12 16 String? appViewProvider, 13 17 String Function()? appViewProviderResolver, 14 - }) : _bluesky = bluesky, 15 - _moderationService = moderationService, 18 + Future<AuthTokens?> Function()? onUnauthorized, 19 + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, 20 + }) : _moderationService = moderationService, 16 21 _appViewContext = AppViewRequestContext( 17 22 appViewProvider: appViewProvider, 18 23 appViewProviderResolver: appViewProviderResolver, 19 - ); 24 + ) { 25 + _authRecovery = UnauthorizedRecoveryRunner<Bluesky>( 26 + initialClient: bluesky, 27 + onUnauthorized: onUnauthorized, 28 + clientFactory: blueskyClientFactory ?? createBlueskyClient, 29 + onUnauthorizedException: (error, stackTrace) { 30 + log.w('notifications.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); 31 + }, 32 + ); 33 + } 20 34 21 - final Bluesky _bluesky; 35 + late final UnauthorizedRecoveryRunner<Bluesky> _authRecovery; 22 36 final ModerationService? _moderationService; 23 37 final AppViewRequestContext _appViewContext; 24 38 25 39 Future<NotificationListResult> listNotifications({String? cursor, int limit = 50}) async { 26 - final response = await _bluesky.notification.listNotifications( 27 - cursor: cursor, 28 - limit: limit, 29 - $headers: _appViewContext.appBskyHeadersForEndpoint( 30 - 'app.bsky.notification.listNotifications', 31 - await _moderationService?.headersForRequest(), 32 - ), 40 + final headers = _appViewContext.appBskyHeadersForEndpoint( 41 + 'app.bsky.notification.listNotifications', 42 + await _moderationService?.headersForRequest(), 43 + ); 44 + final response = await _authRecovery.run( 45 + (client) => client.notification.listNotifications(cursor: cursor, limit: limit, $headers: headers), 33 46 ); 34 47 35 48 return NotificationListResult( ··· 40 53 } 41 54 42 55 Future<int> getUnreadCount() async { 43 - final response = await _bluesky.notification.getUnreadCount( 44 - $headers: _appViewContext.appBskyHeadersForEndpoint( 45 - 'app.bsky.notification.getUnreadCount', 46 - await _moderationService?.headersForRequest(), 47 - ), 56 + final headers = _appViewContext.appBskyHeadersForEndpoint( 57 + 'app.bsky.notification.getUnreadCount', 58 + await _moderationService?.headersForRequest(), 48 59 ); 60 + final response = await _authRecovery.run((client) => client.notification.getUnreadCount($headers: headers)); 49 61 return response.data.count; 50 62 } 51 63 52 64 Future<void> updateSeen() async { 53 - await _bluesky.notification.updateSeen( 54 - seenAt: DateTime.now(), 55 - $headers: _appViewContext.appBskyHeadersForEndpoint( 56 - 'app.bsky.notification.updateSeen', 57 - await _moderationService?.headersForRequest(), 58 - ), 65 + final headers = _appViewContext.appBskyHeadersForEndpoint( 66 + 'app.bsky.notification.updateSeen', 67 + await _moderationService?.headersForRequest(), 59 68 ); 69 + await _authRecovery.run((client) => client.notification.updateSeen(seenAt: DateTime.now(), $headers: headers)); 60 70 } 61 71 62 72 Future<void> registerPush({ ··· 67 77 }) async { 68 78 final serviceDid = _appViewContext.notificationServiceDid(); 69 79 final headers = _notificationPushHeaders(await _moderationService?.headersForRequest()); 70 - await _bluesky.notification.registerPush( 71 - serviceDid: serviceDid, 72 - token: token, 73 - platform: _registerPlatformFor(platform), 74 - appId: appId, 75 - ageRestricted: ageRestricted, 76 - $headers: headers, 80 + await _authRecovery.run( 81 + (client) => client.notification.registerPush( 82 + serviceDid: serviceDid, 83 + token: token, 84 + platform: _registerPlatformFor(platform), 85 + appId: appId, 86 + ageRestricted: ageRestricted, 87 + $headers: headers, 88 + ), 77 89 ); 78 90 } 79 91 ··· 84 96 }) async { 85 97 final serviceDid = _appViewContext.notificationServiceDid(); 86 98 final headers = _notificationPushHeaders(await _moderationService?.headersForRequest()); 87 - await _bluesky.notification.unregisterPush( 88 - serviceDid: serviceDid, 89 - token: token, 90 - platform: _unregisterPlatformFor(platform), 91 - appId: appId, 92 - $headers: headers, 99 + await _authRecovery.run( 100 + (client) => client.notification.unregisterPush( 101 + serviceDid: serviceDid, 102 + token: token, 103 + platform: _unregisterPlatformFor(platform), 104 + appId: appId, 105 + $headers: headers, 106 + ), 93 107 ); 94 108 } 95 109 ··· 106 120 107 121 var cursor = ''; 108 122 for (var page = 0; page < maxPages; page++) { 109 - final response = await _bluesky.notification.listNotifications( 110 - cursor: cursor.isEmpty ? null : cursor, 111 - limit: limit, 112 - $headers: _appViewContext.appBskyHeadersForEndpoint( 113 - 'app.bsky.notification.listNotifications', 114 - await _moderationService?.headersForRequest(), 123 + final headers = _appViewContext.appBskyHeadersForEndpoint( 124 + 'app.bsky.notification.listNotifications', 125 + await _moderationService?.headersForRequest(), 126 + ); 127 + final response = await _authRecovery.run( 128 + (client) => client.notification.listNotifications( 129 + cursor: cursor.isEmpty ? null : cursor, 130 + limit: limit, 131 + $headers: headers, 115 132 ), 116 133 ); 117 134
+52 -4
lib/features/notifications/domain/push_registration_service.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:io'; 3 3 4 + import 'package:atproto_core/atproto_core.dart' as atcore show UnauthorizedException; 4 5 import 'package:lazurite/core/logging/app_logger.dart'; 5 6 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 7 import 'package:lazurite/features/notifications/data/notification_repository.dart'; ··· 8 9 9 10 typedef NotificationRepositoryFactory = NotificationRepository Function(AuthTokens tokens); 10 11 typedef DelayFn = Future<void> Function(Duration delay); 12 + typedef PushAuthRecoveryCallback = Future<AuthTokens?> Function(); 11 13 12 14 class PushRegistrationService { 13 15 PushRegistrationService({ ··· 18 20 int maxAttempts = 4, 19 21 DelayFn delayFn = Future.delayed, 20 22 bool Function()? isPushPlatformSupported, 23 + PushAuthRecoveryCallback? authRecovery, 21 24 }) : _tokenProvider = tokenProvider, 22 25 _notificationRepositoryFactory = notificationRepositoryFactory, 23 26 _initialBackoff = initialBackoff, 24 27 _maxAttempts = maxAttempts, 25 28 _delayFn = delayFn, 26 - _isPushPlatformSupported = isPushPlatformSupported ?? (() => Platform.isAndroid || Platform.isIOS); 29 + _isPushPlatformSupported = isPushPlatformSupported ?? (() => Platform.isAndroid || Platform.isIOS), 30 + _authRecovery = authRecovery; 27 31 28 32 final PushTokenProvider _tokenProvider; 29 33 final NotificationRepositoryFactory _notificationRepositoryFactory; ··· 32 36 final int _maxAttempts; 33 37 final DelayFn _delayFn; 34 38 final bool Function() _isPushPlatformSupported; 39 + PushAuthRecoveryCallback? _authRecovery; 35 40 36 41 StreamSubscription<String>? _tokenRefreshSubscription; 37 42 AuthTokens? _activeTokens; ··· 40 45 var _started = false; 41 46 42 47 bool get _supportsPushPlatform => _isPushPlatformSupported(); 48 + 49 + void configureAuthRecovery(PushAuthRecoveryCallback? authRecovery) { 50 + _authRecovery = authRecovery; 51 + } 43 52 44 53 Future<void> start({required AuthTokens? initialTokens}) async { 45 54 if (_started || !_supportsPushPlatform) { ··· 152 161 } 153 162 154 163 Future<void> _register({required AuthTokens tokens, required String token}) async { 164 + var operationTokens = tokens; 155 165 await _retry( 156 166 operation: 'push register', 157 167 action: () async { 158 - await _notificationRepositoryFactory(tokens).registerPush(token: token, appId: appId, platform: _platform); 168 + await _notificationRepositoryFactory( 169 + operationTokens, 170 + ).registerPush(token: token, appId: appId, platform: _platform); 171 + }, 172 + recoverUnauthorized: () async { 173 + final recovered = await _recoverActiveSession(expectedDid: tokens.did); 174 + if (recovered == null) { 175 + return false; 176 + } 177 + operationTokens = recovered; 178 + return true; 159 179 }, 160 180 ); 161 181 162 - _registeredDid = tokens.did; 182 + _registeredDid = operationTokens.did; 163 183 _registeredToken = token; 164 184 } 165 185 ··· 182 202 _registeredToken = null; 183 203 } 184 204 185 - Future<void> _retry({required String operation, required Future<void> Function() action}) async { 205 + Future<void> _retry({ 206 + required String operation, 207 + required Future<void> Function() action, 208 + Future<bool> Function()? recoverUnauthorized, 209 + }) async { 186 210 var backoff = Duration.zero; 187 211 Object? lastError; 188 212 StackTrace? lastStackTrace; ··· 199 223 lastError = error; 200 224 lastStackTrace = stackTrace; 201 225 226 + if (error is atcore.UnauthorizedException && recoverUnauthorized != null) { 227 + final recovered = await recoverUnauthorized(); 228 + if (recovered) { 229 + backoff = Duration.zero; 230 + log.w('Push lifecycle operation recovered auth session: $operation'); 231 + continue; 232 + } 233 + } 234 + 202 235 if (attempt >= _maxAttempts) { 203 236 log.e('Push lifecycle operation failed: $operation', error: error, stackTrace: stackTrace); 204 237 Error.throwWithStackTrace(error, stackTrace); ··· 216 249 if (lastError != null) { 217 250 Error.throwWithStackTrace(lastError, lastStackTrace ?? StackTrace.current); 218 251 } 252 + } 253 + 254 + Future<AuthTokens?> _recoverActiveSession({required String expectedDid}) async { 255 + final recovery = _authRecovery; 256 + if (recovery == null) { 257 + return null; 258 + } 259 + 260 + final recovered = await recovery(); 261 + if (recovered == null || recovered.did != expectedDid) { 262 + return null; 263 + } 264 + 265 + _activeTokens = recovered; 266 + return recovered; 219 267 } 220 268 221 269 NotificationPushPlatform get _platform =>
+42 -21
lib/features/profile/data/profile_repository.dart
··· 10 10 import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 11 11 import 'package:lazurite/core/network/app_view_provider.dart'; 12 12 import 'package:lazurite/core/network/app_view_request_context.dart'; 13 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 14 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 13 15 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 14 16 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 15 17 ··· 21 23 ActorRepositoryServiceResolver? actorRepositoryServiceResolver, 22 24 String? appViewProvider, 23 25 String Function()? appViewProviderResolver, 26 + Future<AuthTokens?> Function()? onUnauthorized, 27 + dynamic Function(AuthTokens tokens)? blueskyClientFactory, 24 28 }) : _database = database, 25 - _bluesky = bluesky, 26 29 _moderationService = moderationService, 27 30 _actorRepoResolver = actorRepositoryServiceResolver ?? _createActorRepositoryServiceResolver(), 28 31 _appViewContext = AppViewRequestContext( 29 32 appViewProvider: appViewProvider, 30 33 appViewProviderResolver: appViewProviderResolver, 31 - ); 34 + ) { 35 + _authRecovery = UnauthorizedRecoveryRunner<dynamic>( 36 + initialClient: bluesky, 37 + onUnauthorized: onUnauthorized, 38 + clientFactory: blueskyClientFactory ?? createBlueskyClient, 39 + onUnauthorizedException: (error, stackTrace) { 40 + log.w('profile.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); 41 + }, 42 + ); 43 + } 32 44 45 + late final UnauthorizedRecoveryRunner<dynamic> _authRecovery; 33 46 final AppDatabase _database; 34 - final dynamic _bluesky; 47 + dynamic get _bluesky => _authRecovery.client; 35 48 final ModerationService? _moderationService; 36 49 final ActorRepositoryServiceResolver? _actorRepoResolver; 37 50 final AppViewRequestContext _appViewContext; ··· 58 71 log.i( 59 72 'ProfileRepository: getProfile request actor=$actor atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 60 73 ); 61 - final response = await _bluesky.actor.getProfile(actor: actor, $headers: headers); 74 + final response = await _authRecovery.run((client) => client.actor.getProfile(actor: actor, $headers: headers)); 62 75 profile = response.data; 63 76 log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); 64 77 } catch (error, stackTrace) { ··· 103 116 final profiles = <ProfileView>[]; 104 117 for (var i = 0; i < normalizedActors.length; i += _maxProfilesBatchSize) { 105 118 final batch = normalizedActors.sublist(i, (i + _maxProfilesBatchSize).clamp(0, normalizedActors.length)); 106 - final response = await _bluesky.actor.getProfiles(actors: batch, $headers: headers); 119 + final response = await _authRecovery.run((client) => client.actor.getProfiles(actors: batch, $headers: headers)); 107 120 profiles.addAll( 108 121 response.data.profiles.where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)), 109 122 ); ··· 114 127 } 115 128 116 129 Future<List<ProfileView>> getSuggestedFollows(String actor) async { 117 - final response = await _bluesky.graph.getSuggestedFollowsByActor( 118 - actor: actor, 119 - $headers: _appViewContext.appBskyHeadersForEndpoint( 120 - 'app.bsky.graph.getSuggestedFollowsByActor', 121 - await _moderationService?.headersForRequest(), 122 - ), 130 + final headers = _appViewContext.appBskyHeadersForEndpoint( 131 + 'app.bsky.graph.getSuggestedFollowsByActor', 132 + await _moderationService?.headersForRequest(), 133 + ); 134 + final response = await _authRecovery.run( 135 + (client) => client.graph.getSuggestedFollowsByActor(actor: actor, $headers: headers), 123 136 ); 124 137 final suggestions = response.data.suggestions; 125 138 final moderationService = _moderationService; ··· 140 153 log.i( 141 154 'ProfileRepository: likes self path actor=$actor endpoint=app.bsky.feed.getActorLikes host=current-session-pds', 142 155 ); 143 - final response = await _bluesky.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers); 156 + final response = await _authRecovery.run( 157 + (client) => client.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers), 158 + ); 144 159 final feed = (response.data.feed as List<dynamic>).whereType<FeedViewPost>().toList(growable: false); 145 160 final moderationService = _moderationService; 146 161 final posts = moderationService == null ··· 161 176 log.i( 162 177 'ProfileRepository: likes non-self list path actor=$actor did=${resolved.did} endpoint=com.atproto.repo.listRecords host=${resolved.pdsHost}', 163 178 ); 164 - final recordsResponse = await _bluesky.atproto.repo.listRecords( 165 - repo: resolved.did, 166 - collection: 'app.bsky.feed.like', 167 - limit: limit.clamp(1, 100), 168 - cursor: cursor, 169 - reverse: false, 170 - $service: resolved.pdsHost, 179 + final recordsResponse = await _authRecovery.run( 180 + (client) => client.atproto.repo.listRecords( 181 + repo: resolved.did, 182 + collection: 'app.bsky.feed.like', 183 + limit: limit.clamp(1, 100), 184 + cursor: cursor, 185 + reverse: false, 186 + $service: resolved.pdsHost, 187 + ), 171 188 ); 172 189 final likeRecords = _extractLikeRecords(recordsResponse.data.records as List<dynamic>); 173 190 if (likeRecords.isEmpty) { ··· 187 204 final subjectUris = likeRecords.map((record) => atp_core.AtUri.parse(record.subjectUri)).toList(growable: false); 188 205 for (var i = 0; i < subjectUris.length; i += _maxPostsHydrationBatchSize) { 189 206 final batch = subjectUris.sublist(i, (i + _maxPostsHydrationBatchSize).clamp(0, subjectUris.length)); 190 - final response = await _bluesky.feed.getPosts(uris: batch, $service: appViewHost, $headers: hydrationHeaders); 207 + final response = await _authRecovery.run( 208 + (client) => client.feed.getPosts(uris: batch, $service: appViewHost, $headers: hydrationHeaders), 209 + ); 191 210 for (final post in response.data.posts) { 192 211 postsByUri[post.uri.toString()] = post; 193 212 } ··· 226 245 log.i( 227 246 'ProfileRepository: getCurrentUserProfile request did=${tokens.did} atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 228 247 ); 229 - final response = await _bluesky.actor.getProfile(actor: tokens.did, $headers: headers); 248 + final response = await _authRecovery.run( 249 + (client) => client.actor.getProfile(actor: tokens.did, $headers: headers), 250 + ); 230 251 log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); 231 252 return response.data; 232 253 } catch (error, stackTrace) {
+10 -1
lib/main.dart
··· 279 279 return widget.localNotificationAdapter.requestPermissions(); 280 280 }), 281 281 ); 282 + widget.pushRegistrationService.configureAuthRecovery( 283 + () => _recoverAuthSession(trigger: 'push_registration_unauthorized'), 284 + ); 282 285 unawaited(widget.pushRegistrationService.start(initialTokens: widget.authBloc.state.tokens)); 283 286 _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { 284 287 unawaited(widget.pushRegistrationService.updateSession(tokens)); ··· 404 407 } 405 408 406 409 GoRouter _createRouter() { 407 - return AppRouter(authBloc: widget.authBloc, navigatorObserver: _navigatorObserver).router; 410 + return AppRouter( 411 + authBloc: widget.authBloc, 412 + navigatorObserver: _navigatorObserver, 413 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 414 + ).router; 408 415 } 409 416 410 417 String _sessionKeyFor(AuthState state) => state.tokens?.did ?? 'guest'; ··· 619 626 bluesky: bluesky, 620 627 moderationService: service, 621 628 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 629 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 622 630 ); 623 631 }, 624 632 ), ··· 627 635 bluesky: bluesky, 628 636 moderationService: context.read<ModerationService>(), 629 637 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 638 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 630 639 ), 631 640 ), 632 641 RepositoryProvider(
+85
test/features/compose/bloc/compose_bloc_test.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' show Blob, BlobRef; 1 4 import 'package:bloc_test/bloc_test.dart'; 2 5 import 'package:flutter_test/flutter_test.dart'; 3 6 import 'package:lazurite/core/database/app_database.dart'; ··· 9 12 class MockComposeRepository extends Mock implements ComposeRepository {} 10 13 11 14 class FakeDraftsCompanion extends Fake implements DraftsCompanion {} 15 + 16 + final _jpegBytes = <int>[0xFF, 0xD8, 0xFF, 0xE0, 0, 16, 0x4A, 0x46, 0x49, 0x46, 0, 1]; 12 17 13 18 DraftEntry _makeDraft({ 14 19 int id = 1, ··· 34 39 updatedAt: DateTime(2025, 1, 1), 35 40 scheduledAt: scheduledAt, 36 41 ); 42 + 43 + File _writeTempImage(String name, List<int> bytes) { 44 + final dir = Directory.systemTemp.createTempSync('lazurite_compose_test_'); 45 + addTearDown(() { 46 + if (dir.existsSync()) { 47 + dir.deleteSync(recursive: true); 48 + } 49 + }); 50 + final file = File('${dir.path}/$name'); 51 + file.writeAsBytesSync(bytes); 52 + return file; 53 + } 37 54 38 55 void main() { 39 56 group('ComposeBloc', () { ··· 744 761 ), 745 762 ).captured.single; 746 763 expect(embed, isNull); 764 + }, 765 + ); 766 + 767 + blocTest<ComposeBloc, ComposeState>( 768 + 'uploads images and creates image embed with full blob records', 769 + build: () { 770 + when(() => mockRepository.uploadBlobRecord(any(), mimeType: any(named: 'mimeType'))).thenAnswer( 771 + (_) async => const Blob( 772 + ref: BlobRef(link: 'bafkreiimageblob'), 773 + mimeType: 'image/jpeg', 774 + size: 12, 775 + ), 776 + ); 777 + when( 778 + () => mockRepository.createPost( 779 + text: any(named: 'text'), 780 + facets: any(named: 'facets'), 781 + embed: any(named: 'embed'), 782 + reply: any(named: 'reply'), 783 + repo: any(named: 'repo'), 784 + ), 785 + ).thenAnswer((_) async => true); 786 + return composeBloc; 787 + }, 788 + seed: () { 789 + final image = _writeTempImage('photo.jpg', _jpegBytes); 790 + return ComposeState.ready( 791 + text: 'Photo', 792 + graphemeCount: 5, 793 + isEmpty: false, 794 + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'A photo', width: 640, height: 480)], 795 + ); 796 + }, 797 + act: (bloc) => bloc.add(const PostSubmitted()), 798 + wait: const Duration(milliseconds: 10), 799 + expect: () => [ 800 + isA<ComposeState>().having((s) => s.isSubmitting, 'isSubmitting', true), 801 + isA<ComposeState>().having((s) => s.isSuccess, 'isSuccess', true), 802 + ], 803 + verify: (_) { 804 + final uploadedBytes = 805 + verify(() => mockRepository.uploadBlobRecord(captureAny(), mimeType: 'image/jpeg')).captured.single 806 + as List<int>; 807 + expect(uploadedBytes, _jpegBytes); 808 + final embed = 809 + verify( 810 + () => mockRepository.createPost( 811 + text: any(named: 'text'), 812 + facets: any(named: 'facets'), 813 + embed: captureAny(named: 'embed'), 814 + reply: any(named: 'reply'), 815 + repo: any(named: 'repo'), 816 + ), 817 + ).captured.single 818 + as Map<String, dynamic>; 819 + 820 + expect(embed[r'$type'], 'app.bsky.embed.images'); 821 + final images = embed['images'] as List<dynamic>; 822 + expect(images, hasLength(1)); 823 + final image = images.single as Map<String, dynamic>; 824 + expect(image['alt'], 'A photo'); 825 + expect(image['aspectRatio'], {'width': 640, 'height': 480}); 826 + expect(image['image'], { 827 + r'$type': 'blob', 828 + 'ref': {r'$link': 'bafkreiimageblob'}, 829 + 'mimeType': 'image/jpeg', 830 + 'size': 12, 831 + }); 747 832 }, 748 833 ); 749 834
+114
test/features/compose/data/compose_repository_auth_recovery_test.dart
··· 1 + import 'dart:typed_data'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' 4 + show Blob, BlobRef, HttpMethod, HttpStatus, RateLimit, UnauthorizedException, XRPCError, XRPCRequest, XRPCResponse; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 + import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 8 + 9 + void main() { 10 + group('ComposeRepository auth recovery', () { 11 + test('refreshes and retries blob uploads after unauthorized response', () async { 12 + final initialClient = _FakeBlueskyClient( 13 + atproto: _FakeAtprotoService(repo: _FakeRepoService(throwUnauthorizedOnce: true)), 14 + ); 15 + final refreshedClient = _FakeBlueskyClient(atproto: _FakeAtprotoService(repo: _FakeRepoService())); 16 + var recoveryCalls = 0; 17 + 18 + final repository = ComposeRepository( 19 + bluesky: initialClient, 20 + onUnauthorized: () async { 21 + recoveryCalls += 1; 22 + return const AuthTokens( 23 + accessToken: 'fresh-access', 24 + refreshToken: 'fresh-refresh', 25 + did: 'did:plc:test', 26 + handle: 'test.bsky.social', 27 + ); 28 + }, 29 + blueskyClientFactory: (_) => refreshedClient, 30 + ); 31 + 32 + final blob = await repository.uploadBlobRecord([1, 2, 3], mimeType: 'image/png'); 33 + 34 + expect(recoveryCalls, 1); 35 + expect(initialClient.atproto.repo.uploadAttempts, 1); 36 + expect(refreshedClient.atproto.repo.uploadAttempts, 1); 37 + expect(blob, isNotNull); 38 + expect(blob!.mimeType, 'image/png'); 39 + expect(blob.size, 3); 40 + expect(refreshedClient.atproto.repo.lastUploadedBytes, [1, 2, 3]); 41 + expect(refreshedClient.atproto.repo.lastUploadHeaders, {'Content-Type': 'image/png'}); 42 + }); 43 + }); 44 + } 45 + 46 + class _FakeBlueskyClient { 47 + const _FakeBlueskyClient({required this.atproto}); 48 + 49 + final _FakeAtprotoService atproto; 50 + } 51 + 52 + class _FakeAtprotoService { 53 + const _FakeAtprotoService({required this.repo}); 54 + 55 + final _FakeRepoService repo; 56 + } 57 + 58 + class _FakeRepoService { 59 + _FakeRepoService({this.throwUnauthorizedOnce = false}); 60 + 61 + final bool throwUnauthorizedOnce; 62 + int uploadAttempts = 0; 63 + List<int>? lastUploadedBytes; 64 + Map<String, String>? lastUploadHeaders; 65 + 66 + Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ 67 + required Uint8List bytes, 68 + Map<String, String>? $headers, 69 + }) async { 70 + uploadAttempts += 1; 71 + if (throwUnauthorizedOnce && uploadAttempts == 1) { 72 + throw _unauthorizedException(); 73 + } 74 + 75 + lastUploadedBytes = bytes.toList(); 76 + lastUploadHeaders = $headers; 77 + return _FakeResponse( 78 + _FakeUploadBlobData( 79 + Blob( 80 + mimeType: $headers?['Content-Type'] ?? 'image/jpeg', 81 + size: bytes.length, 82 + ref: const BlobRef(link: 'bafkreirefreshed'), 83 + ), 84 + ), 85 + ); 86 + } 87 + } 88 + 89 + class _FakeResponse<T> { 90 + const _FakeResponse(this.data); 91 + 92 + final T data; 93 + } 94 + 95 + class _FakeUploadBlobData { 96 + const _FakeUploadBlobData(this.blob); 97 + 98 + final Blob blob; 99 + } 100 + 101 + UnauthorizedException _unauthorizedException() { 102 + return UnauthorizedException( 103 + XRPCResponse<XRPCError>( 104 + headers: const {}, 105 + status: HttpStatus.unauthorized, 106 + request: XRPCRequest( 107 + method: HttpMethod.post, 108 + url: Uri.parse('https://example.com/xrpc/com.atproto.repo.uploadBlob'), 109 + ), 110 + rateLimit: RateLimit.unlimited(), 111 + data: const XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), 112 + ), 113 + ); 114 + }
+214 -7
test/features/compose/presentation/compose_screen_test.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:io'; 3 + 1 4 import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 6 import 'package:flutter/material.dart'; 3 7 import 'package:flutter_bloc/flutter_bloc.dart'; 4 8 import 'package:flutter_test/flutter_test.dart'; 5 9 import 'package:lazurite/core/database/app_database.dart'; 6 - import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 10 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 12 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 8 13 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 14 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 15 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 16 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 9 17 import 'package:mocktail/mocktail.dart'; 10 18 11 19 class MockComposeBloc extends MockBloc<ComposeEvent, ComposeState> implements ComposeBloc {} 12 20 13 21 class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 22 + 23 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 24 + 25 + class MockProfileRepository extends Mock implements ProfileRepository {} 14 26 15 27 class FakeDraftsCompanion extends Fake implements DraftsCompanion {} 16 28 17 - DraftEntry _makeDraft({int id = 1, String content = 'Draft'}) => DraftEntry( 29 + const _transparentPngBase64 = 30 + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lJRCiQAAAABJRU5ErkJggg=='; 31 + 32 + DraftEntry _makeDraft({int id = 1, String content = 'Draft', DateTime? scheduledAt}) => DraftEntry( 18 33 id: id, 19 34 accountDid: 'did:plc:test', 20 35 content: content, ··· 26 41 rootCid: null, 27 42 createdAt: DateTime(2025, 1, 1), 28 43 updatedAt: DateTime(2025, 1, 1), 29 - scheduledAt: null, 44 + scheduledAt: scheduledAt, 30 45 ); 31 46 47 + File _writeTempImage() { 48 + final dir = Directory.systemTemp.createTempSync('lazurite_compose_screen_test_'); 49 + addTearDown(() { 50 + if (dir.existsSync()) { 51 + dir.deleteSync(recursive: true); 52 + } 53 + }); 54 + final file = File('${dir.path}/image.png'); 55 + file.writeAsBytesSync(base64Decode(_transparentPngBase64)); 56 + return file; 57 + } 58 + 32 59 void main() { 33 60 late MockComposeBloc mockBloc; 34 61 late MockConnectivityCubit connectivityCubit; 62 + late MockAuthBloc authBloc; 63 + late MockProfileRepository profileRepository; 35 64 36 65 setUp(() { 37 66 registerFallbackValue(FakeDraftsCompanion()); ··· 45 74 ); 46 75 mockBloc = MockComposeBloc(); 47 76 connectivityCubit = MockConnectivityCubit(); 77 + authBloc = MockAuthBloc(); 78 + profileRepository = MockProfileRepository(); 48 79 when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 49 80 whenListen( 50 81 connectivityCubit, 51 82 const Stream<ConnectivityState>.empty(), 52 83 initialState: const ConnectivityState.online(), 53 84 ); 85 + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 86 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 54 87 }); 55 88 56 89 tearDown(() { 57 90 mockBloc.close(); 91 + authBloc.close(); 58 92 }); 59 93 60 - Widget buildSubject({ComposeScreen screen = const ComposeScreen()}) => MaterialApp( 61 - home: MultiBlocProvider( 94 + Widget buildSubject({ComposeScreen screen = const ComposeScreen(), ProfileRepository? profileRepositoryOverride}) { 95 + Widget home = MultiBlocProvider( 62 96 providers: [ 63 97 BlocProvider<ComposeBloc>.value(value: mockBloc), 64 98 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 99 + BlocProvider<AuthBloc>.value(value: authBloc), 65 100 ], 66 101 child: screen, 67 - ), 68 - ); 102 + ); 103 + 104 + final repository = profileRepositoryOverride; 105 + if (repository != null) { 106 + home = RepositoryProvider<ProfileRepository>.value(value: repository, child: home); 107 + } 108 + 109 + return MaterialApp(home: home); 110 + } 69 111 70 112 void seedState(ComposeState state) { 71 113 whenListen(mockBloc, Stream.value(state), initialState: state); ··· 109 151 expect(find.text('@river.bsky.social '), findsOneWidget); 110 152 verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); 111 153 }); 154 + 155 + testWidgets('autofocuses the composer text field', (tester) async { 156 + seedState(const ComposeState.ready()); 157 + 158 + await tester.pumpWidget(buildSubject()); 159 + await tester.pump(); 160 + 161 + final textField = tester.widget<TextField>(find.byType(TextField)); 162 + expect(textField.autofocus, isTrue); 163 + }); 164 + }); 165 + 166 + group('composer avatar', () { 167 + testWidgets('renders an initial fallback avatar', (tester) async { 168 + seedState(const ComposeState.ready()); 169 + 170 + await tester.pumpWidget(buildSubject()); 171 + await tester.pump(); 172 + 173 + expect(find.byKey(const ValueKey('compose_author_avatar')), findsOneWidget); 174 + expect(find.byType(ProfileAvatar), findsOneWidget); 175 + }); 176 + 177 + testWidgets('uses authenticated display name for avatar initials', (tester) async { 178 + const authState = AuthState.authenticated( 179 + AuthTokens(accessToken: 'token', did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 180 + ); 181 + when(() => authBloc.state).thenReturn(authState); 182 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authState); 183 + seedState(const ComposeState.ready()); 184 + 185 + await tester.pumpWidget(buildSubject()); 186 + await tester.pump(); 187 + 188 + expect(find.text('RT'), findsOneWidget); 189 + }); 190 + 191 + testWidgets('loads authenticated user avatar when profile repository is available', (tester) async { 192 + const authState = AuthState.authenticated( 193 + AuthTokens(accessToken: 'token', did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 194 + ); 195 + when(() => authBloc.state).thenReturn(authState); 196 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authState); 197 + when(() => profileRepository.getProfile('did:plc:river')).thenAnswer( 198 + (_) async => ProfileViewDetailed( 199 + did: 'did:plc:river', 200 + handle: 'river.bsky.social', 201 + displayName: 'River Tam', 202 + avatar: 'https://example.com/avatar.jpg', 203 + indexedAt: DateTime.utc(2026), 204 + ), 205 + ); 206 + seedState(const ComposeState.ready()); 207 + 208 + await tester.pumpWidget(buildSubject(profileRepositoryOverride: profileRepository)); 209 + await tester.pump(); 210 + 211 + verify(() => profileRepository.getProfile('did:plc:river')).called(1); 212 + expect(find.byKey(const ValueKey('compose_author_avatar')), findsOneWidget); 213 + }); 112 214 }); 113 215 114 216 group('edit mode', () { ··· 262 364 }); 263 365 }); 264 366 367 + group('image alt text', () { 368 + testWidgets('shows image preview while editing alt text and saves changes', (tester) async { 369 + final image = _writeTempImage(); 370 + seedState( 371 + ComposeState.ready( 372 + isEmpty: false, 373 + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'Existing description')], 374 + ), 375 + ); 376 + 377 + await tester.pumpWidget(buildSubject()); 378 + await tester.pump(); 379 + 380 + await tester.tap(find.text('ALT')); 381 + await tester.pumpAndSettle(); 382 + 383 + expect(find.text('Alt text'), findsOneWidget); 384 + expect(find.byKey(const ValueKey('alt-text-image-preview')), findsOneWidget); 385 + expect(find.text('Existing description'), findsOneWidget); 386 + 387 + await tester.enterText(find.byKey(const ValueKey('alt-text-field')), 'A clearer image description'); 388 + await tester.tap(find.widgetWithText(FilledButton, 'Save')); 389 + await tester.pumpAndSettle(); 390 + 391 + verify(() => mockBloc.add(const AltTextUpdated(index: 0, altText: 'A clearer image description'))).called(1); 392 + }); 393 + }); 394 + 395 + group('video alt text', () { 396 + testWidgets('shows video preview while editing alt text and saves changes', (tester) async { 397 + seedState( 398 + const ComposeState.ready( 399 + isEmpty: false, 400 + videoAttachment: VideoAttachment( 401 + localPath: '/tmp/composer-video.mp4', 402 + status: VideoUploadStatus.ready, 403 + altText: 'Existing video description', 404 + ), 405 + ), 406 + ); 407 + 408 + await tester.pumpWidget(buildSubject()); 409 + await tester.pump(); 410 + 411 + await tester.tap(find.byIcon(Icons.subtitles_outlined)); 412 + await tester.pumpAndSettle(); 413 + 414 + expect(find.text('Video alt text'), findsOneWidget); 415 + expect(find.byKey(const ValueKey('video-alt-preview')), findsOneWidget); 416 + expect(find.byKey(const ValueKey('video-alt-preview-filename')), findsOneWidget); 417 + expect(find.text('Existing video description'), findsOneWidget); 418 + 419 + await tester.enterText(find.byKey(const ValueKey('video-alt-text-field')), 'A clearer video description'); 420 + await tester.tap(find.widgetWithText(FilledButton, 'Save')); 421 + await tester.pumpAndSettle(); 422 + 423 + verify(() => mockBloc.add(const VideoAltTextUpdated('A clearer video description'))).called(1); 424 + }); 425 + }); 426 + 265 427 group('inline drafts panel (Bug #3)', () { 266 428 testWidgets('drafts panel is hidden initially', (tester) async { 267 429 seedState(const ComposeState.ready()); ··· 316 478 expect(find.text('No drafts saved'), findsOneWidget); 317 479 }); 318 480 481 + testWidgets('tapping save draft toolbar action fires DraftSaved', (tester) async { 482 + seedState(const ComposeState.ready(text: 'Save this', graphemeCount: 9, isEmpty: false)); 483 + 484 + await tester.pumpWidget(buildSubject()); 485 + await tester.pump(); 486 + 487 + await tester.tap(find.byTooltip('Save draft')); 488 + await tester.pump(); 489 + 490 + verify(() => mockBloc.add(const DraftSaved())).called(1); 491 + }); 492 + 319 493 testWidgets('shows draft items when drafts are loaded', (tester) async { 320 494 seedState( 321 495 const ComposeState.ready().copyWith(drafts: [_makeDraft(content: 'My saved draft')], isLoadingDrafts: false), ··· 329 503 await tester.pump(const Duration(milliseconds: 300)); 330 504 331 505 expect(find.text('My saved draft'), findsOneWidget); 506 + }); 507 + 508 + testWidgets('shows scheduled badges in the inline draft list', (tester) async { 509 + seedState( 510 + const ComposeState.ready().copyWith( 511 + drafts: [_makeDraft(content: 'Scheduled draft', scheduledAt: DateTime(2026, 6, 1, 12))], 512 + isLoadingDrafts: false, 513 + ), 514 + ); 515 + 516 + await tester.pumpWidget(buildSubject()); 517 + await tester.pump(); 518 + 519 + await tester.tap(find.byIcon(Icons.drive_file_rename_outline)); 520 + await tester.pump(); 521 + await tester.pump(const Duration(milliseconds: 300)); 522 + 523 + expect(find.text('Scheduled draft'), findsOneWidget); 524 + expect(find.text('Scheduled'), findsOneWidget); 525 + }); 526 + 527 + testWidgets('scheduled pill clear action is accessible and dispatches ScheduleCleared', (tester) async { 528 + seedState(ComposeState.ready(scheduledAt: DateTime(2026, 6, 1, 12))); 529 + 530 + await tester.pumpWidget(buildSubject()); 531 + await tester.pump(); 532 + 533 + expect(find.byTooltip('Clear scheduled time'), findsOneWidget); 534 + 535 + await tester.tap(find.byTooltip('Clear scheduled time')); 536 + await tester.pump(); 537 + 538 + verify(() => mockBloc.add(const ScheduleCleared())).called(1); 332 539 }); 333 540 334 541 testWidgets('shows loading indicator while drafts are loading', (tester) async {
+15
test/features/notifications/cubit/unread_count_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:flutter_test/flutter_test.dart'; 3 5 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; ··· 90 92 91 93 await Future.delayed(const Duration(milliseconds: 100)); 92 94 await cubit.close(); 95 + 96 + expect(cubit.isClosed, true); 97 + }); 98 + 99 + test('does not emit when an in-flight poll completes after close', () async { 100 + final unreadCount = Completer<int>(); 101 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) => unreadCount.future); 102 + 103 + final cubit = UnreadCountCubit(notificationRepository: mockNotificationRepository); 104 + 105 + await cubit.close(); 106 + unreadCount.complete(9); 107 + await Future<void>.delayed(Duration.zero); 93 108 94 109 expect(cubit.isClosed, true); 95 110 });
+58
test/features/notifications/data/notification_repository_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:bluesky/bluesky.dart'; 4 5 import 'package:flutter_test/flutter_test.dart'; 5 6 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 7 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 + import 'package:http/http.dart' as http; 6 9 import 'package:mocktail/mocktail.dart'; 7 10 8 11 class MockNotificationRepository extends Mock implements NotificationRepository {} ··· 90 93 91 94 verify(() => mockRepository.updateSeen()).called(1); 92 95 }); 96 + 97 + test('refreshes and retries unread count after unauthorized response', () async { 98 + var initialRequests = 0; 99 + var refreshedRequests = 0; 100 + var recoveryCalls = 0; 101 + late final Bluesky refreshedClient; 102 + final initialClient = _buildBluesky( 103 + getClient: (url, {headers}) async { 104 + initialRequests += 1; 105 + return http.Response( 106 + '{"error":"Unauthorized","message":"\\"exp\\" claim timestamp check failed"}', 107 + 401, 108 + request: http.Request('GET', url), 109 + ); 110 + }, 111 + ); 112 + refreshedClient = _buildBluesky( 113 + getClient: (url, {headers}) async { 114 + refreshedRequests += 1; 115 + return http.Response('{"count":7}', 200, request: http.Request('GET', url)); 116 + }, 117 + ); 118 + 119 + final repository = NotificationRepository( 120 + bluesky: initialClient, 121 + onUnauthorized: () async { 122 + recoveryCalls += 1; 123 + return const AuthTokens( 124 + accessToken: 'fresh-access', 125 + refreshToken: 'fresh-refresh', 126 + did: 'did:plc:test', 127 + handle: 'test.bsky.social', 128 + ); 129 + }, 130 + blueskyClientFactory: (_) => refreshedClient, 131 + ); 132 + 133 + final count = await repository.getUnreadCount(); 134 + 135 + expect(count, 7); 136 + expect(initialRequests, 1); 137 + expect(recoveryCalls, 1); 138 + expect(refreshedRequests, 1); 139 + }); 93 140 }); 94 141 95 142 group('NotificationListResult', () { ··· 126 173 }); 127 174 }); 128 175 } 176 + 177 + Bluesky _buildBluesky({required GetClient getClient}) { 178 + return Bluesky.fromSession( 179 + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), 180 + service: 'example.com', 181 + getClient: getClient, 182 + postClient: (url, {headers, body, encoding}) async { 183 + return http.Response('{}', 200, request: http.Request('POST', url)); 184 + }, 185 + ); 186 + }
+89
test/features/notifications/domain/push_registration_service_test.dart
··· 1 1 import 'dart:async'; 2 2 3 + import 'package:atproto_core/atproto_core.dart' 4 + show HttpMethod, HttpStatus, RateLimit, UnauthorizedException, XRPCError, XRPCRequest, XRPCResponse; 3 5 import 'package:flutter_test/flutter_test.dart'; 4 6 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 5 7 import 'package:lazurite/features/notifications/data/notification_repository.dart'; ··· 47 49 handle: 'account-b.bsky.social', 48 50 service: 'bsky.social', 49 51 ); 52 + const refreshedAccountATokens = AuthTokens( 53 + accessToken: 'fresh-access-a', 54 + refreshToken: 'fresh-refresh-a', 55 + did: 'did:plc:account-a', 56 + handle: 'account-a.bsky.social', 57 + service: 'bsky.social', 58 + ); 50 59 51 60 setUp(() { 52 61 tokenProvider = FakePushTokenProvider(); ··· 99 108 PushRegistrationService buildService({ 100 109 int maxAttempts = 4, 101 110 Duration initialBackoff = const Duration(milliseconds: 1), 111 + PushAuthRecoveryCallback? authRecovery, 102 112 }) { 103 113 return PushRegistrationService( 104 114 tokenProvider: tokenProvider, ··· 107 117 initialBackoff: initialBackoff, 108 118 delayFn: (_) async {}, 109 119 isPushPlatformSupported: () => true, 120 + authRecovery: authRecovery, 110 121 ); 111 122 } 112 123 ··· 202 213 expect(attempts, 3); 203 214 }); 204 215 216 + test('refreshes session and retries registration after unauthorized response', () async { 217 + tokenProvider.token = 'token-a'; 218 + final refreshedRepository = MockNotificationRepository(); 219 + when( 220 + () => refreshedRepository.registerPush( 221 + token: any(named: 'token'), 222 + appId: any(named: 'appId'), 223 + platform: any(named: 'platform'), 224 + ageRestricted: any(named: 'ageRestricted'), 225 + ), 226 + ).thenAnswer((_) async {}); 227 + 228 + NotificationRepository factory(AuthTokens tokens) { 229 + if (tokens.accessToken == refreshedAccountATokens.accessToken) { 230 + return refreshedRepository; 231 + } 232 + return accountARepository; 233 + } 234 + 235 + var recoveryCalls = 0; 236 + final service = PushRegistrationService( 237 + tokenProvider: tokenProvider, 238 + notificationRepositoryFactory: factory, 239 + initialBackoff: const Duration(milliseconds: 1), 240 + delayFn: (_) async {}, 241 + isPushPlatformSupported: () => true, 242 + authRecovery: () async { 243 + recoveryCalls += 1; 244 + return refreshedAccountATokens; 245 + }, 246 + ); 247 + addTearDown(service.dispose); 248 + 249 + when( 250 + () => accountARepository.registerPush( 251 + token: any(named: 'token'), 252 + appId: any(named: 'appId'), 253 + platform: any(named: 'platform'), 254 + ageRestricted: any(named: 'ageRestricted'), 255 + ), 256 + ).thenThrow(_unauthorizedException()); 257 + 258 + await service.start(initialTokens: accountATokens); 259 + 260 + expect(recoveryCalls, 1); 261 + verify( 262 + () => accountARepository.registerPush( 263 + token: 'token-a', 264 + appId: 'org.stormlightlabs.lazurite', 265 + platform: any(named: 'platform'), 266 + ageRestricted: null, 267 + ), 268 + ).called(1); 269 + verify( 270 + () => refreshedRepository.registerPush( 271 + token: 'token-a', 272 + appId: 'org.stormlightlabs.lazurite', 273 + platform: any(named: 'platform'), 274 + ageRestricted: null, 275 + ), 276 + ).called(1); 277 + }); 278 + 205 279 test('re-registers on token refresh and unregisters old token first', () async { 206 280 tokenProvider.token = 'token-a'; 207 281 final service = buildService(); ··· 229 303 }); 230 304 }); 231 305 } 306 + 307 + UnauthorizedException _unauthorizedException() { 308 + return UnauthorizedException( 309 + XRPCResponse<XRPCError>( 310 + headers: const {}, 311 + status: HttpStatus.unauthorized, 312 + request: XRPCRequest( 313 + method: HttpMethod.post, 314 + url: Uri.parse('https://example.com/xrpc/app.bsky.notification.registerPush'), 315 + ), 316 + rateLimit: RateLimit.unlimited(), 317 + data: const XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), 318 + ), 319 + ); 320 + }
+52
test/features/profile/data/profile_repository_test.dart
··· 1 1 import 'dart:convert'; 2 2 3 + import 'package:atproto_core/atproto_core.dart' as atp_core; 3 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 5 import 'package:drift/native.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; 6 7 import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 9 import 'package:lazurite/features/profile/data/profile_repository.dart'; 8 10 9 11 void main() { ··· 82 84 expect(cached.handle, profile.handle); 83 85 }); 84 86 87 + test('refreshes and retries getProfile after unauthorized response', () async { 88 + final profile = _buildProfile(); 89 + final initialClient = _FakeBlueskyClient( 90 + actor: _FakeActorService(onGetProfile: (_) async => throw _unauthorizedException()), 91 + ); 92 + final refreshedClient = _FakeBlueskyClient( 93 + actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile)), 94 + ); 95 + var recoveryCalls = 0; 96 + 97 + final repository = ProfileRepository( 98 + database: database, 99 + bluesky: initialClient, 100 + onUnauthorized: () async { 101 + recoveryCalls += 1; 102 + return const AuthTokens( 103 + accessToken: 'fresh-access', 104 + refreshToken: 'fresh-refresh', 105 + did: 'did:plc:alice', 106 + handle: 'alice.bsky.social', 107 + ); 108 + }, 109 + blueskyClientFactory: (_) => refreshedClient, 110 + ); 111 + 112 + final result = await repository.getProfile(profile.did); 113 + 114 + expect(result.did, profile.did); 115 + expect(recoveryCalls, 1); 116 + expect(initialClient.actor.getProfileCalls, 1); 117 + expect(refreshedClient.actor.getProfileCalls, 1); 118 + }); 119 + 85 120 test('falls back to the cached profile when the xrpc request fails', () async { 86 121 final profile = _buildProfile(); 87 122 await database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); ··· 169 204 170 205 final Future<_FakeResponse<ProfileViewDetailed>> Function(String actor) onGetProfile; 171 206 final Future<_FakeProfilesResponse> Function(List<String> actors)? onGetProfiles; 207 + int getProfileCalls = 0; 172 208 173 209 Future<_FakeResponse<ProfileViewDetailed>> getProfile({required String actor, Map<String, String>? $headers}) { 210 + getProfileCalls += 1; 174 211 return onGetProfile(actor); 175 212 } 176 213 ··· 229 266 230 267 final List<ProfileView> suggestions; 231 268 } 269 + 270 + atp_core.UnauthorizedException _unauthorizedException() { 271 + return atp_core.UnauthorizedException( 272 + atp_core.XRPCResponse<atp_core.XRPCError>( 273 + headers: const {}, 274 + status: atp_core.HttpStatus.unauthorized, 275 + request: atp_core.XRPCRequest( 276 + method: atp_core.HttpMethod.get, 277 + url: Uri.parse('https://example.com/xrpc/app.bsky.actor.getProfile'), 278 + ), 279 + rateLimit: atp_core.RateLimit.unlimited(), 280 + data: const atp_core.XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), 281 + ), 282 + ); 283 + }